From 6040a6a8cbc3b260ef4da25a9ac6bd4cde50e0b1 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:20:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Todo=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C=EB=A5=BC=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=B2=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/TodoMutationEventBusImpl.swift | 37 +++++++++++++++++++ .../TodoMutationEventBusImplTests.swift | 27 ++++++++++++++ .../Sources/Entity/TodoMutationEvent.swift | 12 ++++++ .../Protocol/TodoMutationEventBus.swift | 11 ++++++ 4 files changed, 87 insertions(+) create mode 100644 Application/DevLogData/Sources/Repository/TodoMutationEventBusImpl.swift create mode 100644 Application/DevLogData/Tests/Repository/TodoMutationEventBusImplTests.swift create mode 100644 Application/DevLogDomain/Sources/Entity/TodoMutationEvent.swift create mode 100644 Application/DevLogDomain/Sources/Protocol/TodoMutationEventBus.swift diff --git a/Application/DevLogData/Sources/Repository/TodoMutationEventBusImpl.swift b/Application/DevLogData/Sources/Repository/TodoMutationEventBusImpl.swift new file mode 100644 index 00000000..c0f4e9ce --- /dev/null +++ b/Application/DevLogData/Sources/Repository/TodoMutationEventBusImpl.swift @@ -0,0 +1,37 @@ +// +// TodoMutationEventBusImpl.swift +// DevLogData +// +// Created by opfic on 6/6/26. +// + +import Foundation +import DevLogDomain + +actor TodoMutationEventBusImpl: TodoMutationEventBus { + private var continuations = [UUID: AsyncStream.Continuation]() + + func publish(_ event: TodoMutationEvent) async { + continuations.values.forEach { $0.yield(event) } + } + + func events() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream(of: TodoMutationEvent.self) + + continuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeContinuation(id: id) + } + } + + return stream + } +} + +private extension TodoMutationEventBusImpl { + func removeContinuation(id: UUID) { + continuations[id] = nil + } +} diff --git a/Application/DevLogData/Tests/Repository/TodoMutationEventBusImplTests.swift b/Application/DevLogData/Tests/Repository/TodoMutationEventBusImplTests.swift new file mode 100644 index 00000000..2b21d9f1 --- /dev/null +++ b/Application/DevLogData/Tests/Repository/TodoMutationEventBusImplTests.swift @@ -0,0 +1,27 @@ +// +// TodoMutationEventBusImplTests.swift +// DevLogDataTests +// +// Created by opfic on 6/6/26. +// + +import Testing +import DevLogDomain +@testable import DevLogData + +struct TodoMutationEventBusImplTests { + @Test("TodoMutationEventBus는 발행된 이벤트를 관찰자에게 전달한다") + func todoMutationEventBus는_발행된_이벤트를_관찰자에게_전달한다() async { + let bus = TodoMutationEventBusImpl() + let events = await bus.events() + let task = Task { + var iterator = events.makeAsyncIterator() + return await iterator.next() + } + + await bus.publish(.updated("todo-id")) + + let event = await task.value + #expect(event == .updated("todo-id")) + } +} diff --git a/Application/DevLogDomain/Sources/Entity/TodoMutationEvent.swift b/Application/DevLogDomain/Sources/Entity/TodoMutationEvent.swift new file mode 100644 index 00000000..78e10a08 --- /dev/null +++ b/Application/DevLogDomain/Sources/Entity/TodoMutationEvent.swift @@ -0,0 +1,12 @@ +// +// TodoMutationEvent.swift +// DevLogDomain +// +// Created by opfic on 6/6/26. +// + +public enum TodoMutationEvent: Equatable, Sendable { + case updated(String) + case deleted(String) + case restored(String) +} diff --git a/Application/DevLogDomain/Sources/Protocol/TodoMutationEventBus.swift b/Application/DevLogDomain/Sources/Protocol/TodoMutationEventBus.swift new file mode 100644 index 00000000..466b9562 --- /dev/null +++ b/Application/DevLogDomain/Sources/Protocol/TodoMutationEventBus.swift @@ -0,0 +1,11 @@ +// +// TodoMutationEventBus.swift +// DevLogDomain +// +// Created by opfic on 6/6/26. +// + +public protocol TodoMutationEventBus: Sendable { + func publish(_ event: TodoMutationEvent) async + func events() async -> AsyncStream +} From c60f6c69607d56dd9b9f1ecbfab37641dc0dbd49 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:21:51 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20TodoMutationEvent=20=EB=B0=A9?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogData/Sources/DataAssembler.swift | 7 ++- .../Repository/TodoRepositoryImpl.swift | 8 +++- .../Repository/TodoRepositoryImplTests.swift | 46 +++++++++++++++---- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/Application/DevLogData/Sources/DataAssembler.swift b/Application/DevLogData/Sources/DataAssembler.swift index 2c3f0f42..184d9d09 100644 --- a/Application/DevLogData/Sources/DataAssembler.swift +++ b/Application/DevLogData/Sources/DataAssembler.swift @@ -32,11 +32,16 @@ public final class DataAssembler: Assembler { ) } + container.register(TodoMutationEventBus.self) { + TodoMutationEventBusImpl() + } + container.register(TodoRepository.self) { TodoRepositoryImpl( todoService: container.resolve(TodoService.self), todoCategoryService: container.resolve(TodoCategoryService.self), - widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) + widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self), + todoMutationEventBus: container.resolve(TodoMutationEventBus.self) ) } diff --git a/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift index 3f010106..d8e5472b 100644 --- a/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift @@ -13,15 +13,18 @@ final class TodoRepositoryImpl: TodoRepository { private let todoService: TodoService private let todoCategoryService: TodoCategoryService private let widgetSyncEventBus: WidgetSyncEventBus + private let todoMutationEventBus: TodoMutationEventBus init( todoService: TodoService, todoCategoryService: TodoCategoryService, - widgetSyncEventBus: WidgetSyncEventBus + widgetSyncEventBus: WidgetSyncEventBus, + todoMutationEventBus: TodoMutationEventBus ) { self.todoService = todoService self.todoCategoryService = todoCategoryService self.widgetSyncEventBus = widgetSyncEventBus + self.todoMutationEventBus = todoMutationEventBus } func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { @@ -107,6 +110,7 @@ final class TodoRepositoryImpl: TodoRepository { func upsertTodo(_ todo: Todo) async throws { let todoRequest = TodoRequest.fromDomain(todo) try await upsertTodo(todoRequest) + await todoMutationEventBus.publish(.updated(todo.id)) } func upsertTodo(_ todoDraft: TodoDraft) async throws { @@ -127,6 +131,7 @@ final class TodoRepositoryImpl: TodoRepository { do { try await todoService.deleteTodo(todoId: todoId) widgetSyncEventBus.publish(.syncRequested) + await todoMutationEventBus.publish(.deleted(todoId)) } catch { throw error.toDomain() } @@ -136,6 +141,7 @@ final class TodoRepositoryImpl: TodoRepository { do { try await todoService.undoDeleteTodo(todoId: todoId) widgetSyncEventBus.publish(.syncRequested) + await todoMutationEventBus.publish(.restored(todoId)) } catch { throw error.toDomain() } diff --git a/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift index 2a605eea..7f72e996 100644 --- a/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift +++ b/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift @@ -10,11 +10,11 @@ import Foundation import Testing import DevLogCore import DevLogDomain -@testable import DevLogData +@testable @preconcurrency import DevLogData struct TodoRepositoryImplTests { - @Test("Todo 변경 성공 시 위젯 동기화 이벤트를 발행한다") - func todo_변경_성공_시_위젯_동기화_이벤트를_발행한다() async throws { + @Test("Todo 변경 성공 시 위젯 동기화와 mutation 이벤트를 발행한다") + func todo_변경_성공_시_위젯_동기화와_mutation_이벤트를_발행한다() async throws { let fixture = makeFixture() let todo = makeTodo() @@ -24,10 +24,13 @@ struct TodoRepositoryImplTests { let events = fixture.widgetSyncEventBus.events #expect(events == [.syncRequested, .syncRequested, .syncRequested]) + + let mutationEvents = await fixture.todoMutationEventBus.publishedEvents() + #expect(mutationEvents == [.updated(todo.id), .deleted(todo.id), .restored(todo.id)]) } - @Test("Todo 변경 실패 시 위젯 동기화 이벤트를 발행하지 않는다") - func todo_변경_실패_시_위젯_동기화_이벤트를_발행하지_않는다() async throws { + @Test("Todo 변경 실패 시 위젯 동기화와 mutation 이벤트를 발행하지 않는다") + func todo_변경_실패_시_위젯_동기화와_mutation_이벤트를_발행하지_않는다() async throws { let fixture = makeFixture() let todo = makeTodo() @@ -52,24 +55,30 @@ struct TodoRepositoryImplTests { #expect(error as? TodoRepositoryImplTestsError == .serviceFailed) } - let events = fixture.widgetSyncEventBus.events - #expect(events.isEmpty) + let syncEvents = fixture.widgetSyncEventBus.events + #expect(syncEvents.isEmpty) + + let mutationEvents = await fixture.todoMutationEventBus.publishedEvents() + #expect(mutationEvents.isEmpty) } private func makeFixture() -> Fixture { let todoService = TodoServiceSpy() let todoCategoryService = TodoCategoryServiceSpy() let widgetSyncEventBus = WidgetSyncEventBusSpy() + let todoMutationEventBus = TodoMutationEventBusSpy() let repository = TodoRepositoryImpl( todoService: todoService, todoCategoryService: todoCategoryService, - widgetSyncEventBus: widgetSyncEventBus + widgetSyncEventBus: widgetSyncEventBus, + todoMutationEventBus: todoMutationEventBus ) return Fixture( repository: repository, todoService: todoService, - widgetSyncEventBus: widgetSyncEventBus + widgetSyncEventBus: widgetSyncEventBus, + todoMutationEventBus: todoMutationEventBus ) } @@ -97,6 +106,7 @@ private struct Fixture { let repository: TodoRepositoryImpl let todoService: TodoServiceSpy let widgetSyncEventBus: WidgetSyncEventBusSpy + let todoMutationEventBus: TodoMutationEventBusSpy } private actor TodoServiceSpy: TodoService { @@ -159,6 +169,24 @@ private final class WidgetSyncEventBusSpy: WidgetSyncEventBus { } } +private actor TodoMutationEventBusSpy: TodoMutationEventBus { + private var capturedEvents = [TodoMutationEvent]() + + func publish(_ event: TodoMutationEvent) async { + capturedEvents.append(event) + } + + func publishedEvents() -> [TodoMutationEvent] { + capturedEvents + } + + func events() async -> AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } +} + private enum TodoRepositoryImplTestsError: Error, Equatable { case serviceFailed case unexpectedCall From d350929ca660f381837dcbeb9a136c401269e7b1 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:26:53 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20TodoMutationEvent=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=99=88=20=ED=99=94=EB=A9=B4=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeViewCoordinator.swift | 25 +++++++++++++++++++ .../Sources/Home/Home/HomeViewModel.swift | 5 +++- .../Sources/Main/MainView.swift | 1 + 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 303baba1..a7a9201d 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -18,6 +18,8 @@ final class HomeViewCoordinator { private let container: DIContainer @ObservationIgnored private var cancellable: AnyCancellable? + @ObservationIgnored + private var mutationTask: Task? init(container: DIContainer) { self.container = container @@ -34,10 +36,33 @@ final class HomeViewCoordinator { ) } + deinit { + mutationTask?.cancel() + } + func fetchData() { viewModel.send(.fetchData) } + func refreshRecentTodos() { + viewModel.send(.refreshRecentTodos) + } + + func bindTodoMutationEvent() { + guard mutationTask == nil else { return } + + let bus = container.resolve(TodoMutationEventBus.self) + mutationTask = Task { [weak self] in + let events = await bus.events() + for await event in events { + switch event { + case .updated, .deleted, .restored: + self?.refreshRecentTodos() + } + } + } + } + func bindWindowEvent(_ windowEvent: TodoEditorWindowEvent) { guard cancellable == nil else { return } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift index 476a6129..c5fcdb26 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift @@ -36,6 +36,7 @@ final class HomeViewModel: StorePattern { enum Action { case fetchData + case refreshRecentTodos case networkStatusChanged(Bool) case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) @@ -136,7 +137,7 @@ final class HomeViewModel: StorePattern { switch action { case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected - case .fetchData, .setPresentation, .setAlert, .refreshWebPages, + case .fetchData, .refreshRecentTodos, .setPresentation, .setAlert, .refreshWebPages, .tapTodoCategory, .orderTodoCategory, .updateWebPageURLInput, .addWebPage, .deleteWebPage, .undoDeleteWebPage, .finishDeleteWebPageToast: effects = reduceByView(action, state: &state) @@ -252,6 +253,8 @@ private extension HomeViewModel { switch action { case .fetchData: return [.fetchTodoCategoryPreferences, .fetchRecentTodos, .fetchWebPages] + case .refreshRecentTodos: + return [.fetchRecentTodos] case .refreshWebPages: return [.fetchWebPages] case .setPresentation(let presentation, let isPresented): diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index f6c61af8..05688354 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -48,6 +48,7 @@ struct MainView: View { .onAppear { coordinator.viewModel.send(.onAppear) homeViewCoordinator.bindWindowEvent(windowEvent) + homeViewCoordinator.bindTodoMutationEvent() todoWindowCoordinator.bindWindowEvent(windowEvent) } .onChange(of: selectedTab, initial: true) { _, newValue in From c1bc46c33804b4cebdea77bdd749cfd23679a6f6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:35:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20HomeViewCoordinator=20stream=20?= =?UTF-8?q?task=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeViewCoordinator.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index a7a9201d..10af6a36 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -13,13 +13,17 @@ import DevLogDomain @MainActor @Observable final class HomeViewCoordinator { + private enum AsyncStreamTaskID { + case todoMutationEvent + } + let viewModel: HomeViewModel let router = NavigationRouter() private let container: DIContainer @ObservationIgnored private var cancellable: AnyCancellable? @ObservationIgnored - private var mutationTask: Task? + private var streamTasks = [AsyncStreamTaskID: Task]() init(container: DIContainer) { self.container = container @@ -37,7 +41,7 @@ final class HomeViewCoordinator { } deinit { - mutationTask?.cancel() + streamTasks.values.forEach { $0.cancel() } } func fetchData() { @@ -49,10 +53,10 @@ final class HomeViewCoordinator { } func bindTodoMutationEvent() { - guard mutationTask == nil else { return } + guard streamTasks[.todoMutationEvent] == nil else { return } let bus = container.resolve(TodoMutationEventBus.self) - mutationTask = Task { [weak self] in + streamTasks[.todoMutationEvent] = Task { [weak self] in let events = await bus.events() for await event in events { switch event { From b3e4ee73fbbbfc9927e8feeeb9e9476011c94f5c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:02:48 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20self=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeViewCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 10af6a36..f5ef30d4 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -59,9 +59,10 @@ final class HomeViewCoordinator { streamTasks[.todoMutationEvent] = Task { [weak self] in let events = await bus.events() for await event in events { + guard let self else { break } switch event { case .updated, .deleted, .restored: - self?.refreshRecentTodos() + self.refreshRecentTodos() } } }