From 3bdacb1418677f47b29efb114637462daa6f5a2f Mon Sep 17 00:00:00 2001 From: Natalia Luzyanina Date: Wed, 16 Apr 2025 18:17:29 +0300 Subject: [PATCH 1/6] keyed replica added --- .../Controllers/ChildRemovingController.swift | 42 ++++++ .../KeyedReplicaChildRemovingController.swift | 43 ++++++ .../KeyedReplicaObserversController.swift | 71 ++++++++++ .../KeyedReplica/KeyedPhysicalReplica.swift | 75 ++++++++++ .../KeyedPhysicalReplicaImplementation.swift | 131 ++++++++++++++++++ .../munkit/KeyedReplica/KeyedReplica.swift | 25 ++++ .../KeyedReplica/KeyedReplicaEvent.swift | 14 ++ .../KeyedReplica/KeyedReplicaObserver.swift | 100 +++++++++++++ .../KeyedReplica/KeyedReplicaState.swift | 30 ++++ .../ReplicaObserversController.swift | 54 ++++++-- .../munkit/Replica/ObservingState.swift | 2 + .../PhysicalReplica/PhysicalReplica.swift | 3 + .../PhysicalReplicaImplementation.swift | 6 +- .../Sources/munkit/Replica/ReplicaEvent.swift | 28 ++-- .../Sources/munkit/Replica/ReplicaState.swift | 18 ++- 15 files changed, 617 insertions(+), 25 deletions(-) create mode 100644 munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift create mode 100644 munkit/Sources/munkit/KeyedReplica/KeyedReplicaState.swift diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift new file mode 100644 index 0000000..1c5f990 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift @@ -0,0 +1,42 @@ +// +// KeyedReplicaChildRemovingController.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +final class KeyedReplicaChildRemovingController { + private let removeReplica: @Sendable (K) -> Void + + init(removeReplica: @escaping @Sendable (K) -> Void) { + self.removeReplica = removeReplica + } + + func setupAutoRemoving(key: K, replica: any PhysicalReplica) { + let additionalCheckTask = Task { + try await Task.sleep(for: .seconds(0.5)) + + guard Task.isCancelled == false else { + return + } + + if await replica.canBeRemoved { + removeReplica(key) + } + } + + Task { + for await state in replica.stateStream.dropFirst() { + if state.canBeRemoved { + additionalCheckTask.cancel() + removeReplica(key) + break + } else { + additionalCheckTask.cancel() + } + } + } + } +} diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift new file mode 100644 index 0000000..f376986 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift @@ -0,0 +1,43 @@ +// +// KeyedReplicaChildRemovingController.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +final class KeyedReplicaChildRemovingController { + private let removeReplica: @Sendable (K) -> Void + + init(removeReplica: @escaping @Sendable (K) -> Void) { + self.removeReplica = removeReplica + } + + func setupAutoRemoving(key: K, replica: any PhysicalReplica) { + let additionalCheckTask = Task { + try await Task.sleep(for: .seconds(0.5)) + + guard Task.isCancelled == false else { + return + } + + if await replica.canBeRemoved { + removeReplica(key) + } + } + + // TODO: + Task { + for await state in replica.stateStream.dropFirst() { + if state.canBeRemoved { + additionalCheckTask.cancel() + removeReplica(key) + break + } else { + additionalCheckTask.cancel() + } + } + } + } +} diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift new file mode 100644 index 0000000..01b8889 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift @@ -0,0 +1,71 @@ +// +// KeyedReplicaObserversController.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +actor KeyedReplicaObserversController { + private var keyedReplicaState: KeyedReplicaState + private let eventStreamContinuation: AsyncStream>.Continuation + + public init( + initialState: KeyedReplicaState, + eventStreamContinuation: AsyncStream>.Continuation + ) { + self.keyedReplicaState = initialState + self.eventStreamContinuation = eventStreamContinuation + } + + func updateState(_ newState: KeyedReplicaState) async { + self.keyedReplicaState = newState + } + + public func setupObserverCounting(replica: any PhysicalReplica) async { + // TODO + for await event in await replica.observersControllerEventStream.stream { + if case .observerCountChanged(let observingState) = event { + + let previousCount = observingState.observersCountInfo.previousCount + let count = observingState.observersCountInfo.count + let previousActiveCount = observingState.observersCountInfo.previousActiveCount + let activeCount = observingState.observersCountInfo.activeCount + + let replicaWithObserversCountDiff = { + if count > 0 && previousCount == 0 { return 1 } + if count == 0 && previousCount > 0 { return -1 } + return 0 + }() + + let replicaWithActiveObserversCountDiff = { + if activeCount > 0 && previousActiveCount == 0 { return 1 } + if activeCount == 0 && previousActiveCount > 0 { return -1 } + return 0 + }() + + if replicaWithObserversCountDiff != 0 || replicaWithActiveObserversCountDiff != 0 { + let currentState = keyedReplicaState + +// let newState = KeyedReplicaState( +// replicaCount: currentState.replicaCount, +// replicaWithObserversCount: currentState.replicaWithObserversCount + replicaWithObserversCountDiff, +// replicaWithActiveObserversCount: currentState.replicaWithActiveObserversCount + replicaWithActiveObserversCountDiff +// ) + let replicaWithObserversCount = currentState.replicaWithObserversCount + replicaWithObserversCountDiff + let replicaWithActiveObserversCount = currentState.replicaWithActiveObserversCount + replicaWithActiveObserversCountDiff + + // TODO: + // в оригинальной реплике нет этого события, подумать + eventStreamContinuation.yield( + .replicaObserverCountChanged( + replicaWithObserversCount: replicaWithObserversCount, + replicaWithActiveObserversCount: replicaWithActiveObserversCount + ) + ) + } + } + } + } +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift new file mode 100644 index 0000000..665ea6a --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift @@ -0,0 +1,75 @@ +// +// KeyedPhysicalReplica.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +public protocol KeyedPhysicalReplica: KeyedReplica where K: Hashable & Sendable, T: Sendable { + /// Уникальный идентификатор реплики + var id: String { get } + + /// Человекочитаемое имя (для отладки) + var name: String { get } + + /// Возвращает текущее состояние реплики для указанного ключа + func getCurrentState(for key: K) -> ReplicaState? + + /// Заменяет текущие данные новыми для указанного ключа + /// Примечание: Не влияет на свежесть данных + func setData(_ data: T, for key: K) async throws + + /// Модифицирует текущие данные с помощью функции преобразования, если они существуют для ключа + /// Примечание: Не влияет на свежесть данных + func mutateData(for key: K, transform: @Sendable (T) throws -> T) async throws + + // MARK: - Управление данными + + /// Помечает данные как устаревшие для указанного ключа (если они существуют) + /// Может инициировать обновление в зависимости от режима InvalidationMode + func invalidate(key: K, mode: InvalidationMode) async + + /// Помечает данные как свежие для указанного ключа (если они существуют) + func makeFresh(key: K) async + + /// Отменяет текущий запрос для указанного ключа (если он выполняется) + func cancel(key: K) + + /// Отменяет запрос и очищает данные для указанного ключа + /// Параметр removeFromStorage определяет, будут ли данные удалены из хранилища + func clear(key: K, removeFromStorage: Bool) async + + /// Очищает ошибку в состоянии реплики для указанного ключа + func clearError(key: K) async + + /// Отменяет все сетевые запросы и очищает данные во всех дочерних репликах + func clearAll() async + + // MARK: - Оптимистичные обновления + + /// Начинает оптимистичное обновление для указанного ключа + /// Наблюдаемые данные будут немедленно преобразованы функцией update + func beginOptimisticUpdate(key: K, update: OptimisticUpdate) async + + /// Подтверждает оптимистичное обновление для указанного ключа + /// Реплика "забывает" предыдущие данные + func commitOptimisticUpdate(key: K, update: OptimisticUpdate) async + + /// Откатывает оптимистичное обновление для указанного ключа + /// Наблюдаемые данные возвращаются к исходному состоянию + func rollbackOptimisticUpdate(key: K, update: OptimisticUpdate) async + + // MARK: - Доступ к репликам + + /// Выполняет действие с PhysicalReplica для указанного ключа + /// Если реплика не существует - создает ее + func withReplica(key: K, action: @Sendable (any PhysicalReplica) async throws -> Void) async throws + + /// Выполняет действие с PhysicalReplica для указанного ключа, если она существует + func withExistingReplica(key: K, action: @Sendable (any PhysicalReplica) async throws -> Void) async throws + + /// Выполняет действие для каждой дочерней PhysicalReplica + func forEachReplica(action: @Sendable (K, any PhysicalReplica) async throws -> Void) async throws +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift new file mode 100644 index 0000000..916b3a7 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift @@ -0,0 +1,131 @@ +// +// KeyedPhysicalReplicaImplementation.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +public actor KeyedPhysicalReplicaImplementation: KeyedReplica { + public let name: String + public let id: String = UUID().uuidString + + private let replicaFactory: @Sendable (K) async -> any PhysicalReplica + private var keyedReplicaState: KeyedReplicaState + private var replicas: [K: any PhysicalReplica] = [:] + + private var observerStateStreams: [AsyncStreamBundle] = [] + public let observersControllerEventStream: AsyncStreamBundle> + public let eventStream: AsyncStreamBundle> + + private let childRemovingController: KeyedReplicaChildRemovingController + private let observerCountController: KeyedReplicaObserversController + + public init( + name: String, + replicaFactory: @escaping @Sendable (K) async -> any PhysicalReplica + ) { + self.name = name + self.replicaFactory = replicaFactory + + self.eventStream = AsyncStream.makeStream(of: KeyedReplicaEvent.self) + + self.keyedReplicaState = KeyedReplicaState.empty + + self.observersControllerEventStream = AsyncStream.makeStream(of: KeyedReplicaEvent.self) + + self.observerCountController = KeyedReplicaObserversController( + initialState: keyedReplicaState, + eventStreamContinuation: observersControllerEventStream.continuation + ) + + // TODO: + self.childRemovingController = KeyedReplicaChildRemovingController(removeReplica: { [weak self] key in + // await self?.removeReplica(key: key) + }) + + Task { + await processEvents() + } + } + + public func observe(activityStream: AsyncStream, key: AsyncStream) async -> + KeyedReplicaObserver { + let stateStreamBundle = AsyncStream.makeStream() + observerStateStreams.append(stateStreamBundle) + + return KeyedReplicaObserver( + activityStream: activityStream, + keyStream: key, + replicaProvider: { [weak self] key in + return await self?.getOrCreateReplica(key: key) + } + ) + } + + public func refresh(key: K) async { + await getOrCreateReplica(key: key).refresh() + } + + public func revalidate(key: K) async { + await getOrCreateReplica(key: key).revalidate() + } + + public func getData(key: K, forceRefresh: Bool) async throws -> T { + try await getOrCreateReplica(key: key).fetchData(forceRefresh: forceRefresh) + } + + private func processEvents() { + let eventStreams = [ + observersControllerEventStream.stream + ] + + Task { + await withTaskGroup(of: Void.self) { group in + for stream in eventStreams { + group.addTask { [weak self] in + for await event in stream { + await self?.handleEvent(event) + } + } + } + } + } + } + + private func handleEvent(_ event: KeyedReplicaEvent) { + // TODO: + } + + private func getOrCreateReplica(key: K) async -> any PhysicalReplica { + if let replica = replicas[key] { + return replica + } + + let replica = await replicaFactory(key) + replicas[key] = replica + + await childRemovingController.setupAutoRemoving(key: key, replica: replica) + await observerCountController.setupObserverCounting(replica: replica) + + let newCount = replicas.count + keyedReplicaState.replicaCount = newCount + + await updateState(keyedReplicaState) + + eventStream.continuation.yield(.replicaCreated(key: key, replica: replica)) + + return replica + } + + private func updateState(_ newState: KeyedReplicaState) async { + print("⚖️", name, #function, newState) + + keyedReplicaState = newState + + await observerCountController.updateState(newState) + + observerStateStreams.forEach { $0.continuation.yield(keyedReplicaState) } + } +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift new file mode 100644 index 0000000..e99f4ff --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift @@ -0,0 +1,25 @@ +// +// KeyedReplica.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +public protocol KeyedReplica: Actor where T: Sendable, K: Hashable & Sendable { + associatedtype T: Sendable + associatedtype K: Hashable & Sendable + + /// Starts observing a keyed replica. Returns a ReplicaObserver that provides access to replica state and error events. + /// В оригинале метод возвращает ReplicaObserver + func observe(activityStream: AsyncStream, key: AsyncStream) async -> KeyedReplicaObserver + + /// Loads fresh data from the network for a given key. + func refresh(key: K) async + + /// Loads fresh data from the network for a given key if the data is stale. + func revalidate(key: K) async + + /// Loads and returns data for a given key. Throws an error if the operation fails. + /// Never returns stale data. Makes a network request if data is stale. + func getData(key: K, forceRefresh: Bool) async throws -> T +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift new file mode 100644 index 0000000..2a7f0b4 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift @@ -0,0 +1,14 @@ +// +// KeyedReplicaEvent.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +public enum KeyedReplicaEvent: Sendable { + case replicaCreated(key: K, replica: any PhysicalReplica) + case replicaRemoved(key: K, replicaId: String) + case replicaObserverCountChanged(replicaWithObserversCount: Int, replicaWithActiveObserversCount: Int) +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift new file mode 100644 index 0000000..18a7673 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift @@ -0,0 +1,100 @@ +// +// KeyedReplicaObserver.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +public actor KeyedReplicaObserver { // : ReplicaObserver + public let stateStream: AsyncStream> + private let eventStream: AsyncStream> + + private let activityStream: AsyncStream + private let keyStream: AsyncStream + private let replicaProvider: @Sendable (K) async -> (any PhysicalReplica)? + + private var currentReplica: (any PhysicalReplica)? + private var currentReplicaObserver: ReplicaObserver? + private var stateObservingTask: Task? + private var errorObservingTask: Task? + + private let stateContinuation: AsyncStream>.Continuation + private let eventContinuation: AsyncStream>.Continuation + + public init( + activityStream: AsyncStream, + keyStream: AsyncStream, + replicaProvider: @escaping @Sendable (K) async -> (any PhysicalReplica)? + ) { + self.activityStream = activityStream + self.keyStream = keyStream + self.replicaProvider = replicaProvider + + let (stateStream, stateContinuation) = AsyncStream.makeStream(of: ReplicaState.self) + self.stateStream = stateStream + self.stateContinuation = stateContinuation + // stateContinuation.yield(ReplicaState()) + + let (eventStream, eventContinuation) = AsyncStream.makeStream(of: ReplicaEvent.self) + self.eventStream = eventStream + self.eventContinuation = eventContinuation + + Task { + await launchObserving() + } + } + + public func stopObserving() async { + await cancelCurrentObserving() + } + + private func launchObserving() async { + for await currentKey in keyStream { + await cancelCurrentObserving() + await launchObservingForKey(currentKey: currentKey) + } + } + + private func launchObservingForKey(currentKey: K?) async { + guard let key = currentKey, let replica = await replicaProvider(key) else { + stateContinuation.yield(ReplicaState.createEmpty(hasStorage: false)) + return + } + + currentReplica = replica + currentReplicaObserver = await replica.observe(activityStream: activityStream) + + guard let observer = currentReplicaObserver else { + stateContinuation.yield(ReplicaState.createEmpty(hasStorage: false)) + return + } + + stateObservingTask = Task { + for await state in observer.stateStream { + stateContinuation.yield(state) + } + } + // TODO: +// errorObservingTask = Task { +// for await event in observer.eventStream { +// if case .loading(.error(let error)) = event { +// eventContinuation.yield(event) +// } +// } +// } + } + + private func cancelCurrentObserving() async { + currentReplica = nil + await currentReplicaObserver?.stopObserving() + currentReplicaObserver = nil + + stateObservingTask?.cancel() + stateObservingTask = nil + + errorObservingTask?.cancel() + errorObservingTask = nil + } +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaState.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaState.swift new file mode 100644 index 0000000..35daf9a --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaState.swift @@ -0,0 +1,30 @@ +// +// KeyedReplicaState.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +public struct KeyedReplicaState: Sendable { + public var replicaCount: Int + public let replicaWithObserversCount: Int + public let replicaWithActiveObserversCount: Int + + public static let empty = KeyedReplicaState( + replicaCount: 0, + replicaWithObserversCount: 0, + replicaWithActiveObserversCount: 0 + ) + + public init( + replicaCount: Int, + replicaWithObserversCount: Int, + replicaWithActiveObserversCount: Int + ) { + self.replicaCount = replicaCount + self.replicaWithObserversCount = replicaWithObserversCount + self.replicaWithActiveObserversCount = replicaWithActiveObserversCount + } +} diff --git a/munkit/Sources/munkit/Replica/Controllers/ReplicaObserversController.swift b/munkit/Sources/munkit/Replica/Controllers/ReplicaObserversController.swift index 7efc831..d3639d1 100644 --- a/munkit/Sources/munkit/Replica/Controllers/ReplicaObserversController.swift +++ b/munkit/Sources/munkit/Replica/Controllers/ReplicaObserversController.swift @@ -27,16 +27,26 @@ actor ReplicaObserversController where T: Sendable { func handleObserverAdded(observerId: UUID, isActive: Bool) { let currentObservingState = replicaState.observingState + let updatedObserverIds = currentObservingState.observerIds.union([observerId]) + let updatedActiveObserverIds = isActive ? currentObservingState.activeObserverIds.union([observerId]) : currentObservingState.activeObserverIds let updatedObservingTime = isActive ? .now : currentObservingState.observingTime + let observersCountInfo = ObserversCountInfo( + count: updatedObserverIds.count, + activeCount: updatedActiveObserverIds.count, + previousCount: currentObservingState.observerIds.count, + previousActiveCount: currentObservingState.activeObserverIds.count + ) + let newObservingState = ObservingState( - observerIds: currentObservingState.observerIds.union([observerId]), + observerIds: updatedObserverIds, activeObserverIds: updatedActiveObserverIds, - observingTime: updatedObservingTime + observingTime: updatedObservingTime, + observersCountInfo: observersCountInfo ) emitStateChangeIfNeeded( @@ -54,10 +64,21 @@ actor ReplicaObserversController where T: Sendable { let updatedObservingTime = isLastActive ? .timeInPast(.now) : currentObservingState.observingTime + let observerIds = currentObservingState.observerIds.subtracting([observerId]) + let activeObserverIds = currentObservingState.activeObserverIds.subtracting([observerId]) + + let observersCountInfo = ObserversCountInfo( + count: observerIds.count, + activeCount: activeObserverIds.count, + previousCount: currentObservingState.observerIds.count, + previousActiveCount: currentObservingState.activeObserverIds.count + ) + let newObservingState = ObservingState( - observerIds: currentObservingState.observerIds.subtracting([observerId]), - activeObserverIds: currentObservingState.activeObserverIds.subtracting([observerId]), - observingTime: updatedObservingTime + observerIds: observerIds, + activeObserverIds: activeObserverIds, + observingTime: updatedObservingTime, + observersCountInfo: observersCountInfo ) emitStateChangeIfNeeded( @@ -73,10 +94,18 @@ actor ReplicaObserversController where T: Sendable { var updatedActiveObserverIds = currentObservingState.activeObserverIds updatedActiveObserverIds.insert(observerId) + let observersCountInfo = ObserversCountInfo( + count: currentObservingState.observerIds.count, + activeCount: updatedActiveObserverIds.count, + previousCount: currentObservingState.observerIds.count, + previousActiveCount: currentObservingState.activeObserverIds.count + ) + let newObservingState = ObservingState( observerIds: currentObservingState.observerIds, activeObserverIds: updatedActiveObserverIds, - observingTime: .now + observingTime: .now, + observersCountInfo: observersCountInfo ) emitStateChangeIfNeeded( @@ -93,11 +122,20 @@ actor ReplicaObserversController where T: Sendable { && currentObservingState.activeObserverIds.contains(observerId) let updatedObservingTime = isLastActive ? .timeInPast(.now) : currentObservingState.observingTime + let activeObserverIds = currentObservingState.activeObserverIds.subtracting([observerId]) + + let observersCountInfo = ObserversCountInfo( + count: currentObservingState.observerIds.count, + activeCount: activeObserverIds.count, + previousCount: currentObservingState.observerIds.count, + previousActiveCount: currentObservingState.activeObserverIds.count + ) let newObservingState = ObservingState( observerIds: currentObservingState.observerIds, - activeObserverIds: currentObservingState.activeObserverIds.subtracting([observerId]), - observingTime: updatedObservingTime + activeObserverIds: activeObserverIds, + observingTime: updatedObservingTime, + observersCountInfo: observersCountInfo ) emitStateChangeIfNeeded( diff --git a/munkit/Sources/munkit/Replica/ObservingState.swift b/munkit/Sources/munkit/Replica/ObservingState.swift index af8e754..a2671d8 100644 --- a/munkit/Sources/munkit/Replica/ObservingState.swift +++ b/munkit/Sources/munkit/Replica/ObservingState.swift @@ -14,6 +14,8 @@ public struct ObservingState: Sendable { /// Время последнего наблюдения за репликой. let observingTime: ObservingTime + var observersCountInfo: ObserversCountInfo + /// Текущий статус наблюдения, основанный на количестве наблюдателей. var status: ObservingStatus { if activeObserverIds.count > 0 { diff --git a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift index 04ce00b..ee29781 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift @@ -10,6 +10,9 @@ import Foundation public protocol PhysicalReplica: Replica where T: Sendable { var name: String { get } + var observersControllerEventStream: AsyncStreamBundle> { get } + var canBeRemoved: Bool { get } + init(storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T, name: String) func clear(invalidationMode: InvalidationMode, removeFromStorage: Bool) async diff --git a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift index ee0bd73..7f4366c 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift @@ -31,6 +31,10 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { private let dataMutationController: ReplicaDataChangingController private let optimisticUpdatesController: ReplicaOptimisticUpdatesController + public var canBeRemoved: Bool { + replicaState.canBeRemoved + } + public init(storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T, name: String) { self.name = name self.storage = storage @@ -202,7 +206,7 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { ] Task { - await withTaskGroup { group in + await withTaskGroup(of: Void.self) { group in for stream in eventStreams { group.addTask { [weak self] in for await event in stream { diff --git a/munkit/Sources/munkit/Replica/ReplicaEvent.swift b/munkit/Sources/munkit/Replica/ReplicaEvent.swift index 1b8aca4..e2e5e86 100644 --- a/munkit/Sources/munkit/Replica/ReplicaEvent.swift +++ b/munkit/Sources/munkit/Replica/ReplicaEvent.swift @@ -6,7 +6,7 @@ // /// Событие, произошедшее в реплике. -enum ReplicaEvent: Sendable where T: Sendable { +public enum ReplicaEvent: Sendable where T: Sendable { /// События, связанные с загрузкой. case loading(LoadingEvent) /// События, связанные со свежестью данных. @@ -23,7 +23,7 @@ enum ReplicaEvent: Sendable where T: Sendable { case optimisticUpdates(OptimisticUpdatesEvent) } -enum OptimisticUpdatesEvent: Sendable where T: Sendable { +public enum OptimisticUpdatesEvent: Sendable where T: Sendable { /// Добавляет обновление в список ожидающих обновлений case begin(data: ReplicaData) /// Подтверждает оптимистичное обновление, применяя его к данным и сохраняя в хранилище. @@ -32,14 +32,14 @@ enum OptimisticUpdatesEvent: Sendable where T: Sendable { case rollback(data: ReplicaData) } -enum ChangingDataEvent: Sendable where T: Sendable { +public enum ChangingDataEvent: Sendable where T: Sendable { /// Замена текущих данных на новые case dataSetting(data: ReplicaData) /// Модификация текущих данных case dataMutating(data: ReplicaData) } -enum LoadingFinished: Sendable where T: Sendable { +public enum LoadingFinished: Sendable where T: Sendable { /// Успешная загрузка с данными. case success(data: ReplicaData) /// Загрузка отменена. @@ -48,7 +48,7 @@ enum LoadingFinished: Sendable where T: Sendable { case error(Error) } -enum LoadingEvent: Sendable where T: Sendable { +public enum LoadingEvent: Sendable where T: Sendable { /// Начало загрузки. case loadingStarted(dataRequested: Bool, preloading: Bool) /// Данные загружены из хранилища. @@ -57,14 +57,14 @@ enum LoadingEvent: Sendable where T: Sendable { case loadingFinished(LoadingFinished) } -enum FreshnessEvent: Sendable { +public enum FreshnessEvent: Sendable { /// Данные стали свежими. case freshened /// Данные устарели. case becameStale } -struct ObserversCountInfo: Sendable { +public struct ObserversCountInfo: Sendable { let count: Int let activeCount: Int let previousCount: Int @@ -72,7 +72,7 @@ struct ObserversCountInfo: Sendable { } extension ReplicaEvent: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .loading(let event): "Loading: \(event)" case .freshness(let event): "Freshness: \(event)" @@ -86,7 +86,7 @@ extension ReplicaEvent: CustomStringConvertible { } extension OptimisticUpdatesEvent: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .begin: "Began update" case .commit: "Committed update" @@ -96,7 +96,7 @@ extension OptimisticUpdatesEvent: CustomStringConvertible { } extension ChangingDataEvent: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .dataSetting: "Data set" case .dataMutating: "Data mutated" @@ -105,7 +105,7 @@ extension ChangingDataEvent: CustomStringConvertible { } extension LoadingFinished: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .success: "Loaded successfully" case .canceled: "Loading canceled" @@ -115,7 +115,7 @@ extension LoadingFinished: CustomStringConvertible { } extension LoadingEvent: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .loadingStarted(let dataRequested, let preloading): "Started loading (dataRequested: \(dataRequested), preloading: \(preloading))" @@ -126,7 +126,7 @@ extension LoadingEvent: CustomStringConvertible { } extension FreshnessEvent: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .freshened: "Data freshened" case .becameStale: "Data stale" @@ -135,7 +135,7 @@ extension FreshnessEvent: CustomStringConvertible { } extension ObserversCountInfo: CustomStringConvertible { - var description: String { + public var description: String { "count: \(count), active: \(activeCount) (prev: \(previousCount), prevActive: \(previousActiveCount))" } } diff --git a/munkit/Sources/munkit/Replica/ReplicaState.swift b/munkit/Sources/munkit/Replica/ReplicaState.swift index 98f08fc..eacf4d4 100644 --- a/munkit/Sources/munkit/Replica/ReplicaState.swift +++ b/munkit/Sources/munkit/Replica/ReplicaState.swift @@ -24,7 +24,15 @@ public struct ReplicaState: Sendable where T: Sendable { var hasFreshData: Bool { data?.isFresh ?? false } - + + var canBeRemoved: Bool { + data == nil && + error == nil && + !loading && + !dataRequested && + observingState.status == .none + } + func copy( loading: Bool? = nil, data: ReplicaData? = nil, @@ -46,7 +54,13 @@ public struct ReplicaState: Sendable where T: Sendable { } static func createEmpty(hasStorage: Bool) -> ReplicaState { - let observingState = ObservingState(observerIds: [], activeObserverIds: [], observingTime: .never) + let observersCountInfo = ObserversCountInfo(count: 0, activeCount: 0, previousCount: 0, previousActiveCount: 0) + let observingState = ObservingState( + observerIds: [], + activeObserverIds: [], + observingTime: .never, + observersCountInfo: observersCountInfo + ) return ReplicaState( loading: false, From 466deb4f6f6cab9d46e05ea0cddc56494770c3d4 Mon Sep 17 00:00:00 2001 From: Natalia Luzyanina Date: Thu, 17 Apr 2025 17:45:57 +0300 Subject: [PATCH 2/6] some fixes --- .../Controllers/ChildRemovingController.swift | 42 ------------------- .../KeyedReplicaChildRemovingController.swift | 10 ++--- .../KeyedPhysicalReplicaImplementation.swift | 23 +++++++--- .../KeyedReplica/KeyedReplicaEvent.swift | 1 + .../PhysicalReplica/PhysicalReplica.swift | 3 +- .../PhysicalReplicaImplementation.swift | 6 ++- .../munkit/Replica/ReplicaClient.swift | 4 +- 7 files changed, 31 insertions(+), 58 deletions(-) delete mode 100644 munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift deleted file mode 100644 index 1c5f990..0000000 --- a/munkit/Sources/munkit/KeyedReplica/Controllers/ChildRemovingController.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// KeyedReplicaChildRemovingController.swift -// munkit -// -// Created by Natalia Luzyanina on 16.04.2025. -// - -import Foundation - -final class KeyedReplicaChildRemovingController { - private let removeReplica: @Sendable (K) -> Void - - init(removeReplica: @escaping @Sendable (K) -> Void) { - self.removeReplica = removeReplica - } - - func setupAutoRemoving(key: K, replica: any PhysicalReplica) { - let additionalCheckTask = Task { - try await Task.sleep(for: .seconds(0.5)) - - guard Task.isCancelled == false else { - return - } - - if await replica.canBeRemoved { - removeReplica(key) - } - } - - Task { - for await state in replica.stateStream.dropFirst() { - if state.canBeRemoved { - additionalCheckTask.cancel() - removeReplica(key) - break - } else { - additionalCheckTask.cancel() - } - } - } - } -} diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift index f376986..2a6014c 100644 --- a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift @@ -7,11 +7,11 @@ import Foundation -final class KeyedReplicaChildRemovingController { - private let removeReplica: @Sendable (K) -> Void +actor KeyedReplicaChildRemovingController { + private let replicaEventStreamContinuation: AsyncStream>.Continuation - init(removeReplica: @escaping @Sendable (K) -> Void) { - self.removeReplica = removeReplica + init(replicaEventStreamContinuation: AsyncStream>.Continuation) { + self.replicaEventStreamContinuation = replicaEventStreamContinuation } func setupAutoRemoving(key: K, replica: any PhysicalReplica) { @@ -23,7 +23,7 @@ final class KeyedReplicaChildRemovingController: KeyedReplica { + public let id: String public let name: String - public let id: String = UUID().uuidString private let replicaFactory: @Sendable (K) async -> any PhysicalReplica private var keyedReplicaState: KeyedReplicaState @@ -19,17 +19,22 @@ public actor KeyedPhysicalReplicaImplementation> public let eventStream: AsyncStreamBundle> + private let childRemovingControllerEventStream: AsyncStreamBundle> + private let childRemovingController: KeyedReplicaChildRemovingController private let observerCountController: KeyedReplicaObserversController public init( + id: String = UUID().uuidString, name: String, replicaFactory: @escaping @Sendable (K) async -> any PhysicalReplica ) { + self.id = id self.name = name self.replicaFactory = replicaFactory self.eventStream = AsyncStream.makeStream(of: KeyedReplicaEvent.self) + self.childRemovingControllerEventStream = AsyncStream.makeStream(of: KeyedReplicaEvent.self) self.keyedReplicaState = KeyedReplicaState.empty @@ -39,17 +44,23 @@ public actor KeyedPhysicalReplicaImplementation, key: AsyncStream) async -> KeyedReplicaObserver { let stateStreamBundle = AsyncStream.makeStream() diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift index 2a7f0b4..d92e71a 100644 --- a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift @@ -11,4 +11,5 @@ public enum KeyedReplicaEvent: Sendable { case replicaCreated(key: K, replica: any PhysicalReplica) case replicaRemoved(key: K, replicaId: String) case replicaObserverCountChanged(replicaWithObserversCount: Int, replicaWithActiveObserversCount: Int) + case replicaCanBeRemoved } diff --git a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift index ee29781..5460602 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift @@ -8,12 +8,13 @@ import Foundation public protocol PhysicalReplica: Replica where T: Sendable { + var id: String { get } var name: String { get } var observersControllerEventStream: AsyncStreamBundle> { get } var canBeRemoved: Bool { get } - init(storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T, name: String) + init(id: String, name: String, storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T) func clear(invalidationMode: InvalidationMode, removeFromStorage: Bool) async func clearError() async diff --git a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift index 7f4366c..8b8cf97 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift @@ -8,6 +8,7 @@ import Foundation public actor PhysicalReplicaImplementation: PhysicalReplica { + public let id: String public let name: String private let storage: (any Storage)? @@ -17,7 +18,7 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { private var observerStateStreams: [AsyncStreamBundle>] = [] private var observerEventStreams: [AsyncStreamBundle>] = [] - private let observersControllerEventStream: AsyncStreamBundle> + public let observersControllerEventStream: AsyncStreamBundle> private let loadingControllerEventStream: AsyncStreamBundle> private let clearingControllerEventStream: AsyncStreamBundle> private let freshnessControllerEventStream: AsyncStreamBundle> @@ -35,7 +36,8 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { replicaState.canBeRemoved } - public init(storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T, name: String) { + public init(id: String = UUID().uuidString, name: String, storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T) { + self.id = id self.name = name self.storage = storage self.dataFetcher = fetcher diff --git a/munkit/Sources/munkit/Replica/ReplicaClient.swift b/munkit/Sources/munkit/Replica/ReplicaClient.swift index 8a8f7b0..b3ea0a5 100644 --- a/munkit/Sources/munkit/Replica/ReplicaClient.swift +++ b/munkit/Sources/munkit/Replica/ReplicaClient.swift @@ -24,9 +24,9 @@ public actor ReplicaClient { } let replica = PhysicalReplicaImplementation( + name: name, storage: storage, - fetcher: fetcher, - name: name + fetcher: fetcher ) if replicas.isEmpty { From 66fe9f9309a4435c6ebb7e266090bbba1455c3b9 Mon Sep 17 00:00:00 2001 From: Natalia Luzyanina Date: Thu, 17 Apr 2025 20:09:47 +0300 Subject: [PATCH 3/6] update replica client with creating keyed replica --- .../KeyedReplicaChildRemovingController.swift | 22 +++---- .../KeyedReplica/KeyedPhysicalReplica.swift | 62 ------------------- .../KeyedPhysicalReplicaImplementation.swift | 7 ++- .../munkit/Replica/ReplicaClient.swift | 46 +++++++++++++- 4 files changed, 59 insertions(+), 78 deletions(-) diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift index 2a6014c..afa7ed5 100644 --- a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift @@ -28,16 +28,16 @@ actor KeyedReplicaChildRemovingController { } // TODO: - Task { - for await state in replica.stateStream.dropFirst() { - if state.canBeRemoved { - additionalCheckTask.cancel() - removeReplica(key) - break - } else { - additionalCheckTask.cancel() - } - } - } +// Task { +// for await state in replica.stateStream.dropFirst() { +// if state.canBeRemoved { +// additionalCheckTask.cancel() +// removeReplica(key) +// break +// } else { +// additionalCheckTask.cancel() +// } +// } +// } } } diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift index 665ea6a..dd62341 100644 --- a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift @@ -8,68 +8,6 @@ import Foundation public protocol KeyedPhysicalReplica: KeyedReplica where K: Hashable & Sendable, T: Sendable { - /// Уникальный идентификатор реплики var id: String { get } - - /// Человекочитаемое имя (для отладки) var name: String { get } - - /// Возвращает текущее состояние реплики для указанного ключа - func getCurrentState(for key: K) -> ReplicaState? - - /// Заменяет текущие данные новыми для указанного ключа - /// Примечание: Не влияет на свежесть данных - func setData(_ data: T, for key: K) async throws - - /// Модифицирует текущие данные с помощью функции преобразования, если они существуют для ключа - /// Примечание: Не влияет на свежесть данных - func mutateData(for key: K, transform: @Sendable (T) throws -> T) async throws - - // MARK: - Управление данными - - /// Помечает данные как устаревшие для указанного ключа (если они существуют) - /// Может инициировать обновление в зависимости от режима InvalidationMode - func invalidate(key: K, mode: InvalidationMode) async - - /// Помечает данные как свежие для указанного ключа (если они существуют) - func makeFresh(key: K) async - - /// Отменяет текущий запрос для указанного ключа (если он выполняется) - func cancel(key: K) - - /// Отменяет запрос и очищает данные для указанного ключа - /// Параметр removeFromStorage определяет, будут ли данные удалены из хранилища - func clear(key: K, removeFromStorage: Bool) async - - /// Очищает ошибку в состоянии реплики для указанного ключа - func clearError(key: K) async - - /// Отменяет все сетевые запросы и очищает данные во всех дочерних репликах - func clearAll() async - - // MARK: - Оптимистичные обновления - - /// Начинает оптимистичное обновление для указанного ключа - /// Наблюдаемые данные будут немедленно преобразованы функцией update - func beginOptimisticUpdate(key: K, update: OptimisticUpdate) async - - /// Подтверждает оптимистичное обновление для указанного ключа - /// Реплика "забывает" предыдущие данные - func commitOptimisticUpdate(key: K, update: OptimisticUpdate) async - - /// Откатывает оптимистичное обновление для указанного ключа - /// Наблюдаемые данные возвращаются к исходному состоянию - func rollbackOptimisticUpdate(key: K, update: OptimisticUpdate) async - - // MARK: - Доступ к репликам - - /// Выполняет действие с PhysicalReplica для указанного ключа - /// Если реплика не существует - создает ее - func withReplica(key: K, action: @Sendable (any PhysicalReplica) async throws -> Void) async throws - - /// Выполняет действие с PhysicalReplica для указанного ключа, если она существует - func withExistingReplica(key: K, action: @Sendable (any PhysicalReplica) async throws -> Void) async throws - - /// Выполняет действие для каждой дочерней PhysicalReplica - func forEachReplica(action: @Sendable (K, any PhysicalReplica) async throws -> Void) async throws } diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift index 6122835..f516ba1 100644 --- a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift @@ -7,7 +7,7 @@ import Foundation -public actor KeyedPhysicalReplicaImplementation: KeyedReplica { +public actor KeyedPhysicalReplicaImplementation: KeyedPhysicalReplica { public let id: String public let name: String @@ -27,7 +27,7 @@ public actor KeyedPhysicalReplicaImplementation any PhysicalReplica + replicaFactory: @escaping @Sendable (K) async -> (any PhysicalReplica) ) { self.id = id self.name = name @@ -89,7 +89,8 @@ public actor KeyedPhysicalReplicaImplementation( @@ -35,6 +36,39 @@ public actor ReplicaClient { return replica } + + + func createKeyedReplica( + name: String, + childName: @Sendable @escaping (K) -> String, + fetcher: @Sendable @escaping (K) async throws -> T + ) async -> any KeyedPhysicalReplica { + + if let replica = await findKeyedReplica(by: name) as? any KeyedPhysicalReplica { + return replica + } + + let replicaFactory: @Sendable (K) async -> any PhysicalReplica = { (key: K) in + let replica = PhysicalReplicaImplementation( + name: childName(key), + storage: nil, + fetcher: { try await fetcher(key) } + ) as any PhysicalReplica + + return replica + } + + let keyedReplica = KeyedPhysicalReplicaImplementation( + name: name, + replicaFactory: replicaFactory + ) + + if keyedReplicas.isEmpty { + keyedReplicas.append(keyedReplica) + } + return keyedReplica + } + private func findReplica(by name: String) async -> (any PhysicalReplica)? { for replica in replicas { if await replica.name == name { @@ -43,5 +77,13 @@ public actor ReplicaClient { } return nil } -} + private func findKeyedReplica(by name: String) async -> (any KeyedPhysicalReplica)? { + for replica in keyedReplicas { + if await replica.name == name { + return replica + } + } + return nil + } +} From 1f17d4944fae5cc3b52fae234d005648eada1526 Mon Sep 17 00:00:00 2001 From: Natalia Luzyanina Date: Fri, 18 Apr 2025 10:47:49 +0300 Subject: [PATCH 4/6] added logic to cast keyed replica observer to replica observer --- .../DNDClassOverviewRepository.swift | 24 +++++++++ .../Models/DNDClassOverviewModel.swift | 54 +++++++++++++++++++ munkit/Package.swift | 2 +- .../KeyedPhysicalReplicaImplementation.swift | 2 +- .../munkit/KeyedReplica/KeyedReplica.swift | 16 +++++- .../KeyedReplica/KeyedReplicaObserver.swift | 6 +-- .../PhysicalReplicaImplementation.swift | 5 +- ...er.swift => PhysicalReplicaObserver.swift} | 4 +- munkit/Sources/munkit/Replica/Replica.swift | 2 +- .../munkit/Replica/ReplicaClient.swift | 4 +- munkit/Sources/munkit/ReplicaObserver.swift | 16 ++++++ munkit/Sources/munkit/WithKeyReplica.swift | 51 ++++++++++++++++++ 12 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassOverviewRepository.swift create mode 100644 Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/Models/DNDClassOverviewModel.swift rename munkit/Sources/munkit/Replica/{ReplicaObserver.swift => PhysicalReplicaObserver.swift} (93%) create mode 100644 munkit/Sources/munkit/ReplicaObserver.swift create mode 100644 munkit/Sources/munkit/WithKeyReplica.swift diff --git a/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassOverviewRepository.swift b/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassOverviewRepository.swift new file mode 100644 index 0000000..0f756ac --- /dev/null +++ b/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassOverviewRepository.swift @@ -0,0 +1,24 @@ +// +// DNDClassOverviewRepository.swift +// munkit-example-core +// +// Created by Natalia Luzyanina on 17.04.2025. +// + +import munkit +import Foundation + +public actor DNDClassOverviewRepository { + private let networkService: MUNNetworkService + + public let replica: any KeyedPhysicalReplica + + public init(networkService: MUNNetworkService) async { + self.networkService = networkService + self.replica = await ReplicaClient.shared.createKeyedReplica( + name: "DNDClassOverview", + childName: { name in "DNDClassOverview \(name)" }, + fetcher: { index in try await networkService.executeRequest(target: .classOverview(index)) } + ) + } +} diff --git a/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/Models/DNDClassOverviewModel.swift b/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/Models/DNDClassOverviewModel.swift new file mode 100644 index 0000000..dab58fc --- /dev/null +++ b/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/Models/DNDClassOverviewModel.swift @@ -0,0 +1,54 @@ +// +// DNDClassOverviewModel.swift +// munkit-example-core +// +// Created by Natalia Luzyanina on 17.04.2025. +// + +import Foundation + +public struct DNDClassOverviewModel: Decodable, Sendable { + public let name: String + public let hitDie: Int + public let savingThrows: [SavingThrow] + public let proficiencies: [Proficiency] + public let spellcasting: Spellcasting? + + enum CodingKeys: String, CodingKey { + case name + case hitDie = "hit_die" + case savingThrows = "saving_throws" + case proficiencies + case spellcasting + } + + public init( + name: String, + hitDie: Int, + savingThrows: [SavingThrow], + proficiencies: [Proficiency], + spellcasting: Spellcasting? + ) { + self.name = name + self.hitDie = hitDie + self.savingThrows = savingThrows + self.proficiencies = proficiencies + self.spellcasting = spellcasting + } + + public struct SavingThrow: Decodable, Sendable { + public let name: String + } + + public struct Proficiency: Decodable, Sendable { + public let name: String + } + + public struct Spellcasting: Decodable, Sendable { + public let info: [Info] + + public struct Info: Decodable, Sendable { + public let desc: [String] + } + } +} diff --git a/munkit/Package.swift b/munkit/Package.swift index 9a801c1..ecc82fd 100644 --- a/munkit/Package.swift +++ b/munkit/Package.swift @@ -11,7 +11,7 @@ let package = Package( products: [ .library( name: "munkit", - targets: ["munkit"]), + targets: ["munkit"]) ], dependencies: [ .package(url: "https://github.com/Moya/Moya.git", exact: "15.0.3") diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift index f516ba1..c73d438 100644 --- a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift @@ -62,7 +62,7 @@ public actor KeyedPhysicalReplicaImplementation, key: AsyncStream) async -> - KeyedReplicaObserver { + any ReplicaObserver { let stateStreamBundle = AsyncStream.makeStream() observerStateStreams.append(stateStreamBundle) diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift index e99f4ff..efbdd65 100644 --- a/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift @@ -11,7 +11,7 @@ public protocol KeyedReplica: Actor where T: Sendable, K: Hashable & Senda /// Starts observing a keyed replica. Returns a ReplicaObserver that provides access to replica state and error events. /// В оригинале метод возвращает ReplicaObserver - func observe(activityStream: AsyncStream, key: AsyncStream) async -> KeyedReplicaObserver + func observe(activityStream: AsyncStream, key: AsyncStream) async -> any ReplicaObserver /// Loads fresh data from the network for a given key. func refresh(key: K) async @@ -23,3 +23,17 @@ public protocol KeyedReplica: Actor where T: Sendable, K: Hashable & Senda /// Never returns stale data. Makes a network request if data is stale. func getData(key: K, forceRefresh: Bool) async throws -> T } + +public extension KeyedReplica { + public func withKey(_ key: K) -> any Replica { + let keyStream = AsyncStream { continuation in + continuation.yield(key) + continuation.finish() + } + return WithKeyReplica(keyedReplica: self, keyStream: keyStream) + } + + public func withKey(keyStream: AsyncStream) -> any Replica { + return WithKeyReplica(keyedReplica: self, keyStream: keyStream) + } +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift index 18a7673..47c8bb2 100644 --- a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift @@ -7,7 +7,7 @@ import Foundation -public actor KeyedReplicaObserver { // : ReplicaObserver +public actor KeyedReplicaObserver: ReplicaObserver { public let stateStream: AsyncStream> private let eventStream: AsyncStream> @@ -16,7 +16,7 @@ public actor KeyedReplicaObserver { // : Re private let replicaProvider: @Sendable (K) async -> (any PhysicalReplica)? private var currentReplica: (any PhysicalReplica)? - private var currentReplicaObserver: ReplicaObserver? + private var currentReplicaObserver: (any ReplicaObserver)? private var stateObservingTask: Task? private var errorObservingTask: Task? @@ -72,7 +72,7 @@ public actor KeyedReplicaObserver { // : Re } stateObservingTask = Task { - for await state in observer.stateStream { + for await state in await observer.stateStream { stateContinuation.yield(state) } } diff --git a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift index 8b8cf97..b2889cc 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift @@ -83,19 +83,20 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { } } - public func observe(activityStream: AsyncStream) async -> ReplicaObserver { + public func observe(activityStream: AsyncStream) async -> any ReplicaObserver { let stateStreamBundle = AsyncStream>.makeStream() observerStateStreams.append(stateStreamBundle) let eventStreamBundle = AsyncStream>.makeStream() observerEventStreams.append(eventStreamBundle) - return await ReplicaObserver( + let replicaObserver = await PhysicalReplicaObserver( activityStream: activityStream, stateStream: stateStreamBundle.stream, eventStream: eventStreamBundle.stream, observersController: observersController ) + return replicaObserver } public func refresh() async { diff --git a/munkit/Sources/munkit/Replica/ReplicaObserver.swift b/munkit/Sources/munkit/Replica/PhysicalReplicaObserver.swift similarity index 93% rename from munkit/Sources/munkit/Replica/ReplicaObserver.swift rename to munkit/Sources/munkit/Replica/PhysicalReplicaObserver.swift index f56b74d..9a7e9c0 100644 --- a/munkit/Sources/munkit/Replica/ReplicaObserver.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplicaObserver.swift @@ -1,5 +1,5 @@ // -// ReplicaObserver.swift +// PhysicalReplicaObserver.swift // MUNKit // // Created by Natalia Luzyanina on 01.04.2025. @@ -7,7 +7,7 @@ import Foundation -public actor ReplicaObserver where T: Sendable { +public actor PhysicalReplicaObserver: ReplicaObserver where T: Sendable { public let stateStream: AsyncStream> private var stateObservingTask: Task? diff --git a/munkit/Sources/munkit/Replica/Replica.swift b/munkit/Sources/munkit/Replica/Replica.swift index 058f154..28b3a4c 100644 --- a/munkit/Sources/munkit/Replica/Replica.swift +++ b/munkit/Sources/munkit/Replica/Replica.swift @@ -12,7 +12,7 @@ public protocol Replica: Actor where T: Sendable { associatedtype T: Sendable /// Starts observing the replica's state. - func observe(activityStream: AsyncStream) async -> ReplicaObserver + func observe(activityStream: AsyncStream) async -> any ReplicaObserver /// Fetches fresh data from the network. /// - Note: Does not trigger a new request if one is already in progress. diff --git a/munkit/Sources/munkit/Replica/ReplicaClient.swift b/munkit/Sources/munkit/Replica/ReplicaClient.swift index 594302b..e5354c2 100644 --- a/munkit/Sources/munkit/Replica/ReplicaClient.swift +++ b/munkit/Sources/munkit/Replica/ReplicaClient.swift @@ -36,9 +36,7 @@ public actor ReplicaClient { return replica } - - - func createKeyedReplica( + public func createKeyedReplica( name: String, childName: @Sendable @escaping (K) -> String, fetcher: @Sendable @escaping (K) async throws -> T diff --git a/munkit/Sources/munkit/ReplicaObserver.swift b/munkit/Sources/munkit/ReplicaObserver.swift new file mode 100644 index 0000000..c25b2b0 --- /dev/null +++ b/munkit/Sources/munkit/ReplicaObserver.swift @@ -0,0 +1,16 @@ +// +// ReplicaObserver.swift +// munkit +// +// Created by Natalia Luzyanina on 18.04.2025. +// + +import Foundation + +public protocol ReplicaObserver: Actor where T: Sendable { + associatedtype T: Sendable + + var stateStream: AsyncStream> { get } + + func stopObserving() async +} diff --git a/munkit/Sources/munkit/WithKeyReplica.swift b/munkit/Sources/munkit/WithKeyReplica.swift new file mode 100644 index 0000000..31f7745 --- /dev/null +++ b/munkit/Sources/munkit/WithKeyReplica.swift @@ -0,0 +1,51 @@ +// +// WithKeyReplica.swift +// munkit +// +// Created by Natalia Luzyanina on 17.04.2025. +// + +import Foundation + +enum MissingKeyError: Error { + case missingKey +} + +actor WithKeyReplica: Replica { + private let keyedReplica: any KeyedReplica + private let keyStream: AsyncStream + + init(keyedReplica: any KeyedReplica, keyStream: AsyncStream) { + self.keyedReplica = keyedReplica + self.keyStream = keyStream + } + + func observe(activityStream: AsyncStream) async -> any ReplicaObserver { + await keyedReplica.observe(activityStream: activityStream, key: keyStream) + } + + func refresh() async { + guard let key = await currentKey() else { return } + await keyedReplica.refresh(key: key) + } + + func revalidate() async { + guard let key = await currentKey() else { return } + await keyedReplica.revalidate(key: key) + } + + func fetchData(forceRefresh: Bool) async throws -> T { + guard let key = await currentKey() else { + throw MissingKeyError.missingKey + } + return try await keyedReplica.getData(key: key, forceRefresh: forceRefresh) + } + + private func currentKey() async -> K? { + var lastKey: K? + for await key in keyStream { + lastKey = key + } + return lastKey + } +} From 6b78f08e04136577a2db298648fb67f4a6e427bb Mon Sep 17 00:00:00 2001 From: Natalia Luzyanina Date: Fri, 18 Apr 2025 13:28:06 +0300 Subject: [PATCH 5/6] fix KeyedReplicaObserversController --- .../DNDClassesRepository.swift | 2 +- .../KeyedReplicaObserversController.swift | 68 +++++++++---------- .../KeyedPhysicalReplicaImplementation.swift | 2 +- .../KeyedReplica/KeyedReplicaObserver.swift | 4 +- .../PhysicalReplica/PhysicalReplica.swift | 2 +- .../PhysicalReplicaImplementation.swift | 5 +- 6 files changed, 40 insertions(+), 43 deletions(-) diff --git a/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassesRepository.swift b/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassesRepository.swift index 3be5e57..f60735b 100644 --- a/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassesRepository.swift +++ b/Examples/munkit-example-core/Sources/munkit-example-core/Repositories/DNDClassesRepository/DNDClassesRepository.swift @@ -16,7 +16,7 @@ public actor DNDClassesRepository { public init(networkService: MUNNetworkService) async { self.networkService = networkService self.replica = await ReplicaClient.shared.createReplica( - name: "DndReplica", + name: "DNDClasses", storage: nil, fetcher: { try await networkService.executeRequest(target: .classes) } ) diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift index 01b8889..d38a3f6 100644 --- a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift @@ -11,7 +11,7 @@ actor KeyedReplicaObserversController { private var keyedReplicaState: KeyedReplicaState private let eventStreamContinuation: AsyncStream>.Continuation - public init( + init( initialState: KeyedReplicaState, eventStreamContinuation: AsyncStream>.Continuation ) { @@ -23,47 +23,41 @@ actor KeyedReplicaObserversController { self.keyedReplicaState = newState } - public func setupObserverCounting(replica: any PhysicalReplica) async { - // TODO - for await event in await replica.observersControllerEventStream.stream { - if case .observerCountChanged(let observingState) = event { + func setupObserverCounting(replica: any PhysicalReplica) async { + Task { + for await event in await replica.eventStream.stream { + if case .observerCountChanged(let observingState) = event { + print("⚡️ KeyedReplica", event) + let previousCount = observingState.observersCountInfo.previousCount + let count = observingState.observersCountInfo.count + let previousActiveCount = observingState.observersCountInfo.previousActiveCount + let activeCount = observingState.observersCountInfo.activeCount - let previousCount = observingState.observersCountInfo.previousCount - let count = observingState.observersCountInfo.count - let previousActiveCount = observingState.observersCountInfo.previousActiveCount - let activeCount = observingState.observersCountInfo.activeCount + let replicaWithObserversCountDiff = { + if count > 0 && previousCount == 0 { return 1 } + if count == 0 && previousCount > 0 { return -1 } + return 0 + }() - let replicaWithObserversCountDiff = { - if count > 0 && previousCount == 0 { return 1 } - if count == 0 && previousCount > 0 { return -1 } - return 0 - }() + let replicaWithActiveObserversCountDiff = { + if activeCount > 0 && previousActiveCount == 0 { return 1 } + if activeCount == 0 && previousActiveCount > 0 { return -1 } + return 0 + }() - let replicaWithActiveObserversCountDiff = { - if activeCount > 0 && previousActiveCount == 0 { return 1 } - if activeCount == 0 && previousActiveCount > 0 { return -1 } - return 0 - }() + if replicaWithObserversCountDiff != 0 || replicaWithActiveObserversCountDiff != 0 { + let currentState = keyedReplicaState ) + let replicaWithObserversCount = currentState.replicaWithObserversCount + replicaWithObserversCountDiff + let replicaWithActiveObserversCount = currentState.replicaWithActiveObserversCount + replicaWithActiveObserversCountDiff - if replicaWithObserversCountDiff != 0 || replicaWithActiveObserversCountDiff != 0 { - let currentState = keyedReplicaState - -// let newState = KeyedReplicaState( -// replicaCount: currentState.replicaCount, -// replicaWithObserversCount: currentState.replicaWithObserversCount + replicaWithObserversCountDiff, -// replicaWithActiveObserversCount: currentState.replicaWithActiveObserversCount + replicaWithActiveObserversCountDiff -// ) - let replicaWithObserversCount = currentState.replicaWithObserversCount + replicaWithObserversCountDiff - let replicaWithActiveObserversCount = currentState.replicaWithActiveObserversCount + replicaWithActiveObserversCountDiff - - // TODO: - // в оригинальной реплике нет этого события, подумать - eventStreamContinuation.yield( - .replicaObserverCountChanged( - replicaWithObserversCount: replicaWithObserversCount, - replicaWithActiveObserversCount: replicaWithActiveObserversCount + // TODO: в оригинальной реплике нет этого события, подумать + eventStreamContinuation.yield( + .replicaObserverCountChanged( + replicaWithObserversCount: replicaWithObserversCount, + replicaWithActiveObserversCount: replicaWithActiveObserversCount + ) ) - ) + } } } } diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift index c73d438..1c2eeca 100644 --- a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift @@ -70,7 +70,7 @@ public actor KeyedPhysicalReplicaImplementation: ReplicaO private let activityStream: AsyncStream private let keyStream: AsyncStream - private let replicaProvider: @Sendable (K) async -> (any PhysicalReplica)? + private let replicaProvider: (K) async -> (any PhysicalReplica)? private var currentReplica: (any PhysicalReplica)? private var currentReplicaObserver: (any ReplicaObserver)? @@ -26,7 +26,7 @@ public actor KeyedReplicaObserver: ReplicaO public init( activityStream: AsyncStream, keyStream: AsyncStream, - replicaProvider: @escaping @Sendable (K) async -> (any PhysicalReplica)? + replicaProvider: @escaping (K) async -> (any PhysicalReplica)? ) { self.activityStream = activityStream self.keyStream = keyStream diff --git a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift index 5460602..29f407e 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift @@ -11,7 +11,7 @@ public protocol PhysicalReplica: Replica where T: Sendable { var id: String { get } var name: String { get } - var observersControllerEventStream: AsyncStreamBundle> { get } + var eventStream: AsyncStreamBundle> { get } var canBeRemoved: Bool { get } init(id: String, name: String, storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T) diff --git a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift index b2889cc..4023f3b 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplicaImplementation.swift @@ -18,7 +18,8 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { private var observerStateStreams: [AsyncStreamBundle>] = [] private var observerEventStreams: [AsyncStreamBundle>] = [] - public let observersControllerEventStream: AsyncStreamBundle> + public let eventStream: AsyncStreamBundle> + private let observersControllerEventStream: AsyncStreamBundle> private let loadingControllerEventStream: AsyncStreamBundle> private let clearingControllerEventStream: AsyncStreamBundle> private let freshnessControllerEventStream: AsyncStreamBundle> @@ -42,6 +43,7 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { self.storage = storage self.dataFetcher = fetcher self.replicaState = ReplicaState.createEmpty(hasStorage: storage != nil) + self.eventStream = AsyncStream.makeStream(of: ReplicaEvent.self) self.observersControllerEventStream = AsyncStream.makeStream(of: ReplicaEvent.self) self.loadingControllerEventStream = AsyncStream.makeStream(of: ReplicaEvent.self) self.clearingControllerEventStream = AsyncStream.makeStream(of: ReplicaEvent.self) @@ -250,6 +252,7 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { await handleClearedErrorEvent() case .observerCountChanged(let observingState): await handleObserverCountChangedEvent(observingState: observingState) + eventStream.continuation.yield(event) case .changing(let changingEvent): await handleDataMutationEvent(changingEvent) case .optimisticUpdates(let optimisticUpdateEvent): From 38f10cf024579f32ed601f282d42c3e5b24118aa Mon Sep 17 00:00:00 2001 From: Natalia Luzyanina Date: Fri, 18 Apr 2025 13:42:00 +0300 Subject: [PATCH 6/6] example for keyed replica added --- .../Source/Services/MobileService.swift | 31 +++++++++ .../ClassOverviewController.swift | 9 +++ .../ClassOverviewFactory.swift | 19 ++++++ .../DndClassOverview/ClassOverviewView.swift | 49 ++++++++++++++ .../ClassOverviewViewModel.swift | 67 +++++++++++++++++++ .../UI/DndClasses/DndClassesCoordinator.swift | 8 ++- .../UI/DndClasses/DndClassesFactory.swift | 1 - .../UI/DndClasses/DndClassesViewModel.swift | 17 ++--- .../exampleApp.xcodeproj/project.pbxproj | 36 ++++++++++ .../KeyedReplicaObserversController.swift | 2 +- 10 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 Examples/munkit-example-ios/Source/Services/MobileService.swift create mode 100644 Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewController.swift create mode 100644 Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewFactory.swift create mode 100644 Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewView.swift create mode 100644 Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewViewModel.swift diff --git a/Examples/munkit-example-ios/Source/Services/MobileService.swift b/Examples/munkit-example-ios/Source/Services/MobileService.swift new file mode 100644 index 0000000..f70912e --- /dev/null +++ b/Examples/munkit-example-ios/Source/Services/MobileService.swift @@ -0,0 +1,31 @@ +// +// MobileService.swift +// exampleApp +// +// Created by Natalia Luzyanina on 18.04.2025. +// + +import Foundation +import Moya +import munkit +import munkit_example_core + +public actor MobileService { + public static let shared = MobileService() + + public let networkService: MUNNetworkService + + private init() { + let tokenProvider = TokenProvider() + let configuration = URLSessionConfiguration.default + configuration.headers = .default + configuration.urlCache = nil + + let apiProvider = MoyaProvider( + session: Session(configuration: configuration, startRequestsImmediately: true), + plugins: [MUNLoggerPlugin.instance] + ) + + self.networkService = MUNNetworkService(apiProvider: apiProvider, tokenRefreshProvider: tokenProvider) + } +} diff --git a/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewController.swift b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewController.swift new file mode 100644 index 0000000..eab217f --- /dev/null +++ b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewController.swift @@ -0,0 +1,9 @@ +import UIKit + +final class ClassOverviewController: HostingController { + init(viewModel: ClassOverviewViewModel) { + super.init(rootView: ClassOverviewView(viewModel: viewModel)) + + view.backgroundColor = .systemBackground + } +} diff --git a/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewFactory.swift b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewFactory.swift new file mode 100644 index 0000000..5351180 --- /dev/null +++ b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewFactory.swift @@ -0,0 +1,19 @@ +import UIKit +import munkit_example_core +import munkit + +enum ClassOverviewFactory { + @MainActor static func createClassOverviewController(id: String) async -> ClassOverviewController { + let repository = await DNDClassOverviewRepository(networkService: MobileService.shared.networkService) + + let replica = await repository.replica.withKey(id) + let viewModel = ClassOverviewViewModel( + id: id, + replica: replica, + repository: repository + ) + let controller = ClassOverviewController(viewModel: viewModel) + + return controller + } +} diff --git a/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewView.swift b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewView.swift new file mode 100644 index 0000000..39704e9 --- /dev/null +++ b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import munkit_example_core + +extension ClassOverviewView { + struct ViewItem { + let name: String + let hitDie: String + let savingThrows: [String] + let proficiencies: [String] + let description: String? + } +} + +struct ClassOverviewView: View { + @ObservedObject var viewModel: ClassOverviewViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let viewItem = viewModel.viewItem { + Text(viewItem.name) + .font(.largeTitle) + .fontWeight(.bold) + .padding(.horizontal) + VStack(alignment: .leading, spacing: 12) { + Text("Hit Die: \(viewItem.hitDie)") + Text("Saving Throws: \(viewItem.savingThrows.joined(separator: ", "))") + Text("Proficiencies: \(viewItem.proficiencies.joined(separator: ", "))") + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding(.horizontal) + + if let description = viewItem.description { + ScrollView { + Text(description) + .font(.body) + .foregroundColor(.secondary) + } + .padding(.horizontal) + } + } + + Spacer() + } + .onAppear { viewModel.startObserving() } + .onDisappear { viewModel.deinitObserver() } + } +} diff --git a/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewViewModel.swift b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewViewModel.swift new file mode 100644 index 0000000..ce2af77 --- /dev/null +++ b/Examples/munkit-example-ios/Source/UI/DndClassOverview/ClassOverviewViewModel.swift @@ -0,0 +1,67 @@ +import Foundation +import munkit +import munkit_example_core + +final class ClassOverviewViewModel: ObservableObject { + @Published private(set) var viewItem: ClassOverviewView.ViewItem? + + private let repository: DNDClassOverviewRepository + private let replica: any Replica + private var observerTask: Task? + private let observerStateStream: AsyncStreamBundle + + private let dndClassId: String + + init( + id: String, + replica: any Replica, + repository: DNDClassOverviewRepository + ) { + self.dndClassId = id + self.replica = replica + self.repository = repository + self.observerStateStream = AsyncStream.makeStream() + } + + @MainActor + func startObserving() { + print("\(self): startObserving") + + observerTask = Task { [weak self] in + guard let self else { + return + } + + let observer = await replica.observe(activityStream: observerStateStream.stream) + + self.observerStateStream.continuation.yield(true) + + for await state in await observer.stateStream { + let model = state.data?.valueWithOptimisticUpdates + + print("🐉 DNDClassOverviewViewModel: \(String(describing: model))") + guard let model else { + return + } + self.viewItem = .init( + name: model.name, + hitDie: "1d\(model.hitDie)", + savingThrows: model.savingThrows.map { $0.name }, + proficiencies: model.proficiencies.map { $0.name }, + description: model.spellcasting.map { $0.info.flatMap { $0.desc }.joined(separator: "\n") } + ) + } + await observer.stopObserving() + } + } + + func deinitObserver() { + observerStateStream.continuation.yield(false) + observerTask?.cancel() + observerTask = nil + } + + deinit { + print("deinit ClassOverviewViewModel") + } +} diff --git a/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesCoordinator.swift b/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesCoordinator.swift index 13fbdfe..85f4799 100644 --- a/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesCoordinator.swift +++ b/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesCoordinator.swift @@ -1,5 +1,11 @@ final class DndClassesCoordinator { weak var router: NavigationRouter? - @MainActor func showClassOverview(for id: String) {} + @MainActor func showClassOverview(for id: String) { + Task { + let controller = await ClassOverviewFactory.createClassOverviewController(id: id) + + router?.push(controller: controller, isAnimated: true) + } + } } diff --git a/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesFactory.swift b/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesFactory.swift index d33814c..137c76a 100644 --- a/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesFactory.swift +++ b/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesFactory.swift @@ -1,5 +1,4 @@ import UIKit -import munkit import munkit_example_core enum DndClassesFactory { diff --git a/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesViewModel.swift b/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesViewModel.swift index cfe4e84..9960a4a 100644 --- a/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesViewModel.swift +++ b/Examples/munkit-example-ios/Source/UI/DndClasses/DndClassesViewModel.swift @@ -8,8 +8,7 @@ final class DndClassesViewModel: ObservableObject { private let coordinator: DndClassesCoordinator private let repository: DNDClassesRepository private let replica: any Replica - private let observerStateStream: AsyncStream - private let observerContinuation: AsyncStream.Continuation + private let observerStateStream: AsyncStreamBundle private var observerTask: Task? init( @@ -20,11 +19,7 @@ final class DndClassesViewModel: ObservableObject { self.coordinator = coordinator self.repository = repository self.replica = replica - - let (observerActive, observerContinuation) = AsyncStream.makeStream() - - self.observerStateStream = observerActive - self.observerContinuation = observerContinuation + self.observerStateStream = AsyncStream.makeStream() } @MainActor @@ -90,10 +85,10 @@ final class DndClassesViewModel: ObservableObject { return } - let observer = await replica.observe(activityStream: observerStateStream) + let observer = await replica.observe(activityStream: observerStateStream.stream) + + observerStateStream.continuation.yield(true) - self.observerContinuation.yield(true) - for await state in await observer.stateStream { let viewItems = state.data?.valueWithOptimisticUpdates.results.map { DndClassesView.ViewItem(id: $0.index, name: $0.name, isLiked: $0.isLiked) @@ -129,7 +124,7 @@ final class DndClassesViewModel: ObservableObject { } func deinitObserver() { - observerContinuation.yield(false) + observerStateStream.continuation.yield(false) observerTask?.cancel() observerTask = nil } diff --git a/Examples/munkit-example-ios/exampleApp.xcodeproj/project.pbxproj b/Examples/munkit-example-ios/exampleApp.xcodeproj/project.pbxproj index 37bc240..1784209 100644 --- a/Examples/munkit-example-ios/exampleApp.xcodeproj/project.pbxproj +++ b/Examples/munkit-example-ios/exampleApp.xcodeproj/project.pbxproj @@ -17,8 +17,13 @@ 6D1FD8CE2D9EBACB00EBD46D /* RootFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1FD8C92D9EBACB00EBD46D /* RootFactory.swift */; }; 6D1FD8CF2D9EBACB00EBD46D /* RootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1FD8C82D9EBACB00EBD46D /* RootCoordinator.swift */; }; 6D1FD8D02D9EBACB00EBD46D /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1FD8CA2D9EBACB00EBD46D /* RootView.swift */; }; + 6D2357752DB17A1D005C1E7E /* ClassOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2357722DB17A1D005C1E7E /* ClassOverviewView.swift */; }; + 6D2357772DB17A1D005C1E7E /* ClassOverviewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2357712DB17A1D005C1E7E /* ClassOverviewFactory.swift */; }; + 6D2357782DB17A1D005C1E7E /* ClassOverviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2357732DB17A1D005C1E7E /* ClassOverviewViewModel.swift */; }; + 6D2357792DB17A1D005C1E7E /* ClassOverviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D23576F2DB17A1D005C1E7E /* ClassOverviewController.swift */; }; 6D4B1A592DAD267C008F9DDF /* munkit-example-core in Frameworks */ = {isa = PBXBuildFile; productRef = 6D4B1A582DAD267C008F9DDF /* munkit-example-core */; }; 6DD1833A2DACFA91008B38D6 /* munkit in Frameworks */ = {isa = PBXBuildFile; productRef = 6DD183392DACFA91008B38D6 /* munkit */; }; + 6DF0B4E72DB262FD009AB85E /* MobileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF0B4E62DB262FD009AB85E /* MobileService.swift */; }; 6DFE1ED52D9A8880006B827D /* DndClassesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFE1ED32D9A8880006B827D /* DndClassesViewModel.swift */; }; 6DFE1ED62D9A8880006B827D /* DndClassesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFE1ED02D9A8880006B827D /* DndClassesCoordinator.swift */; }; 6DFE1ED72D9A8880006B827D /* DndClassesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFE1ED22D9A8880006B827D /* DndClassesView.swift */; }; @@ -46,6 +51,11 @@ 6D1FD8C92D9EBACB00EBD46D /* RootFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootFactory.swift; sourceTree = ""; }; 6D1FD8CA2D9EBACB00EBD46D /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 6D1FD8CB2D9EBACB00EBD46D /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = ""; }; + 6D23576F2DB17A1D005C1E7E /* ClassOverviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassOverviewController.swift; sourceTree = ""; }; + 6D2357712DB17A1D005C1E7E /* ClassOverviewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassOverviewFactory.swift; sourceTree = ""; }; + 6D2357722DB17A1D005C1E7E /* ClassOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassOverviewView.swift; sourceTree = ""; }; + 6D2357732DB17A1D005C1E7E /* ClassOverviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassOverviewViewModel.swift; sourceTree = ""; }; + 6DF0B4E62DB262FD009AB85E /* MobileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileService.swift; sourceTree = ""; }; 6DFE1ECF2D9A8880006B827D /* DndClassesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DndClassesController.swift; sourceTree = ""; }; 6DFE1ED02D9A8880006B827D /* DndClassesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DndClassesCoordinator.swift; sourceTree = ""; }; 6DFE1ED12D9A8880006B827D /* DndClassesFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DndClassesFactory.swift; sourceTree = ""; }; @@ -128,6 +138,7 @@ A577824B1E43E21481C6D68C /* Routers */, 98030BE61FD16270AB0448D8 /* UI */, 8B90DD7C274B82A576C267C2 /* Resources */, + 6DF0B4E92DB2636C009AB85E /* Services */, ); path = Source; sourceTree = ""; @@ -155,6 +166,17 @@ path = Root; sourceTree = ""; }; + 6D2357742DB17A1D005C1E7E /* DndClassOverview */ = { + isa = PBXGroup; + children = ( + 6D23576F2DB17A1D005C1E7E /* ClassOverviewController.swift */, + 6D2357712DB17A1D005C1E7E /* ClassOverviewFactory.swift */, + 6D2357722DB17A1D005C1E7E /* ClassOverviewView.swift */, + 6D2357732DB17A1D005C1E7E /* ClassOverviewViewModel.swift */, + ); + path = DndClassOverview; + sourceTree = ""; + }; 6D8B3E122D9A89E100343A02 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -162,6 +184,14 @@ name = Frameworks; sourceTree = ""; }; + 6DF0B4E92DB2636C009AB85E /* Services */ = { + isa = PBXGroup; + children = ( + 6DF0B4E62DB262FD009AB85E /* MobileService.swift */, + ); + path = Services; + sourceTree = ""; + }; 6DFE1ED42D9A8880006B827D /* DndClasses */ = { isa = PBXGroup; children = ( @@ -195,6 +225,7 @@ children = ( 6DFE1ED42D9A8880006B827D /* DndClasses */, 6D1FD8D12D9EBAD400EBD46D /* Root */, + 6D2357742DB17A1D005C1E7E /* DndClassOverview */, ); path = UI; sourceTree = ""; @@ -339,6 +370,11 @@ 6DFE1ED62D9A8880006B827D /* DndClassesCoordinator.swift in Sources */, 6DFE1ED72D9A8880006B827D /* DndClassesView.swift in Sources */, 6DFE1ED82D9A8880006B827D /* DndClassesFactory.swift in Sources */, + 6D2357752DB17A1D005C1E7E /* ClassOverviewView.swift in Sources */, + 6D2357772DB17A1D005C1E7E /* ClassOverviewFactory.swift in Sources */, + 6DF0B4E72DB262FD009AB85E /* MobileService.swift in Sources */, + 6D2357782DB17A1D005C1E7E /* ClassOverviewViewModel.swift in Sources */, + 6D2357792DB17A1D005C1E7E /* ClassOverviewController.swift in Sources */, 6DFE1ED92D9A8880006B827D /* DndClassesController.swift in Sources */, 6D1FD8CC2D9EBACB00EBD46D /* RootController.swift in Sources */, 6D1FD8CD2D9EBACB00EBD46D /* RootViewModel.swift in Sources */, diff --git a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift index d38a3f6..009d285 100644 --- a/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift @@ -46,7 +46,7 @@ actor KeyedReplicaObserversController { }() if replicaWithObserversCountDiff != 0 || replicaWithActiveObserversCountDiff != 0 { - let currentState = keyedReplicaState ) + let currentState = keyedReplicaState let replicaWithObserversCount = currentState.replicaWithObserversCount + replicaWithObserversCountDiff let replicaWithActiveObserversCount = currentState.replicaWithActiveObserversCount + replicaWithActiveObserversCountDiff