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/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/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/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/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/Controllers/KeyedReplicaChildRemovingController.swift b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaChildRemovingController.swift new file mode 100644 index 0000000..afa7ed5 --- /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 + +actor KeyedReplicaChildRemovingController { + private let replicaEventStreamContinuation: AsyncStream>.Continuation + + init(replicaEventStreamContinuation: AsyncStream>.Continuation) { + self.replicaEventStreamContinuation = replicaEventStreamContinuation + } + + 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 { + replicaEventStreamContinuation.yield(.replicaCanBeRemoved) + } + } + + // 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..009d285 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/Controllers/KeyedReplicaObserversController.swift @@ -0,0 +1,65 @@ +// +// KeyedReplicaObserversController.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +actor KeyedReplicaObserversController { + private var keyedReplicaState: KeyedReplicaState + private let eventStreamContinuation: AsyncStream>.Continuation + + init( + initialState: KeyedReplicaState, + eventStreamContinuation: AsyncStream>.Continuation + ) { + self.keyedReplicaState = initialState + self.eventStreamContinuation = eventStreamContinuation + } + + func updateState(_ newState: KeyedReplicaState) async { + self.keyedReplicaState = newState + } + + 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 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 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..dd62341 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplica.swift @@ -0,0 +1,13 @@ +// +// 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 } +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift new file mode 100644 index 0000000..1c2eeca --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedPhysicalReplicaImplementation.swift @@ -0,0 +1,143 @@ +// +// KeyedPhysicalReplicaImplementation.swift +// munkit +// +// Created by Natalia Luzyanina on 16.04.2025. +// + +import Foundation + +public actor KeyedPhysicalReplicaImplementation: KeyedPhysicalReplica { + public let id: String + public let name: String + + 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 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 + + self.observersControllerEventStream = AsyncStream.makeStream(of: KeyedReplicaEvent.self) + + self.observerCountController = KeyedReplicaObserversController( + initialState: keyedReplicaState, + eventStreamContinuation: observersControllerEventStream.continuation + ) + self.childRemovingController = KeyedReplicaChildRemovingController( + replicaEventStreamContinuation: childRemovingControllerEventStream.continuation + ) + + Task { + await processEvents() + } + } + + private func removeReplica(key: K) async { + let removedReplica = replicas.removeValue(forKey: key) + guard let removedReplica else { + return + } + await eventStream.continuation.yield(.replicaRemoved(key: key, replicaId: removedReplica.id)) + } + + public func observe(activityStream: AsyncStream, key: AsyncStream) async -> + any ReplicaObserver { + let stateStreamBundle = AsyncStream.makeStream() + observerStateStreams.append(stateStreamBundle) + + return KeyedReplicaObserver( + activityStream: activityStream, + keyStream: key, + replicaProvider: { [weak self] key in + 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, + childRemovingControllerEventStream.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..efbdd65 --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplica.swift @@ -0,0 +1,39 @@ +// +// 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 -> any ReplicaObserver + + /// 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 +} + +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/KeyedReplicaEvent.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift new file mode 100644 index 0000000..d92e71a --- /dev/null +++ b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaEvent.swift @@ -0,0 +1,15 @@ +// +// 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) + case replicaCanBeRemoved +} diff --git a/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift b/munkit/Sources/munkit/KeyedReplica/KeyedReplicaObserver.swift new file mode 100644 index 0000000..e747e9a --- /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: (K) async -> (any PhysicalReplica)? + + private var currentReplica: (any PhysicalReplica)? + private var currentReplicaObserver: (any 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 (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 await 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..29f407e 100644 --- a/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift +++ b/munkit/Sources/munkit/Replica/PhysicalReplica/PhysicalReplica.swift @@ -8,9 +8,13 @@ import Foundation public protocol PhysicalReplica: Replica where T: Sendable { + var id: String { get } var name: String { get } - init(storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T, name: String) + var eventStream: AsyncStreamBundle> { get } + var canBeRemoved: Bool { get } + + 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 ee0bd73..4023f3b 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,6 +18,7 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { private var observerStateStreams: [AsyncStreamBundle>] = [] private var observerEventStreams: [AsyncStreamBundle>] = [] + public let eventStream: AsyncStreamBundle> private let observersControllerEventStream: AsyncStreamBundle> private let loadingControllerEventStream: AsyncStreamBundle> private let clearingControllerEventStream: AsyncStreamBundle> @@ -31,11 +33,17 @@ public actor PhysicalReplicaImplementation: PhysicalReplica { private let dataMutationController: ReplicaDataChangingController private let optimisticUpdatesController: ReplicaOptimisticUpdatesController - public init(storage: (any Storage)?, fetcher: @Sendable @escaping () async throws -> T, name: String) { + public var canBeRemoved: Bool { + replicaState.canBeRemoved + } + + 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 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) @@ -77,19 +85,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 { @@ -202,7 +211,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 { @@ -243,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): 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 8a8f7b0..e5354c2 100644 --- a/munkit/Sources/munkit/Replica/ReplicaClient.swift +++ b/munkit/Sources/munkit/Replica/ReplicaClient.swift @@ -9,9 +9,10 @@ import Foundation public actor ReplicaClient { private var replicas: [any PhysicalReplica] = [] + private var keyedReplicas: [any KeyedPhysicalReplica] = [] public static let shared = ReplicaClient() - + private init() {} public func createReplica( @@ -24,9 +25,9 @@ public actor ReplicaClient { } let replica = PhysicalReplicaImplementation( + name: name, storage: storage, - fetcher: fetcher, - name: name + fetcher: fetcher ) if replicas.isEmpty { @@ -35,6 +36,37 @@ public actor ReplicaClient { return replica } + public 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 +75,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 + } +} 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, 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 + } +}