From 58307a248ea83e133e66c05a82053ac263055997 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Wed, 11 Feb 2026 20:10:26 -0800 Subject: [PATCH 1/5] add events stream for affected item from undo/redo --- Sources/SQLiteUndo/UndoCoordinator.swift | 22 ++++-- Sources/SQLiteUndo/UndoEngine.swift | 6 ++ Sources/SQLiteUndo/UndoEvent.swift | 39 ++++++++++ Sources/SQLiteUndo/UndoOperations.swift | 18 ++++- Sources/SQLiteUndoTCA/UndoManaging.swift | 17 ++++- Tests/SQLiteUndoTests/UndoEngineTests.swift | 82 +++++++++++++++++++++ 6 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 Sources/SQLiteUndo/UndoEvent.swift diff --git a/Sources/SQLiteUndo/UndoCoordinator.swift b/Sources/SQLiteUndo/UndoCoordinator.swift index 0b1654b..1d08898 100644 --- a/Sources/SQLiteUndo/UndoCoordinator.swift +++ b/Sources/SQLiteUndo/UndoCoordinator.swift @@ -17,6 +17,9 @@ final class UndoCoordinator: Sendable { private let untrackedTables: Set private let state = LockIsolated(State()) + let events: AsyncStream + private let eventsContinuation: AsyncStream.Continuation + private struct State { var openBarriers: [UUID: OpenBarrier] = [:] @@ -60,6 +63,7 @@ final class UndoCoordinator: Sendable { self.database = database ?? defaultDatabase self.registeredTables = registeredTables self.untrackedTables = untrackedTables + (self.events, self.eventsContinuation) = AsyncStream.makeStream() } /// Begin recording changes for a new undoable action. @@ -189,14 +193,17 @@ final class UndoCoordinator: Sendable { state.withValue { $0.barrierSeqRanges[barrier.id] } ?? SeqRange(startSeq: barrier.startSeq, endSeq: barrier.endSeq) - let newRange = try database.write { db in + let result = try database.write { db in try db.performUndoRedo(startSeq: seqRange.startSeq, endSeq: seqRange.endSeq) } - if let newRange { + if let result { state.withValue { - $0.barrierSeqRanges[barrier.id] = newRange + $0.barrierSeqRanges[barrier.id] = result.seqRange } + eventsContinuation.yield( + UndoEvent(kind: .undo, name: barrier.name, affectedItems: result.affectedItems) + ) } } @@ -213,14 +220,17 @@ final class UndoCoordinator: Sendable { state.withValue { $0.barrierSeqRanges[barrier.id] } ?? SeqRange(startSeq: barrier.startSeq, endSeq: barrier.endSeq) - let newRange = try database.write { db in + let result = try database.write { db in try db.performUndoRedo(startSeq: seqRange.startSeq, endSeq: seqRange.endSeq) } - if let newRange { + if let result { state.withValue { - $0.barrierSeqRanges[barrier.id] = newRange + $0.barrierSeqRanges[barrier.id] = result.seqRange } + eventsContinuation.yield( + UndoEvent(kind: .redo, name: barrier.name, affectedItems: result.affectedItems) + ) } } } diff --git a/Sources/SQLiteUndo/UndoEngine.swift b/Sources/SQLiteUndo/UndoEngine.swift index d8f2366..f4a5f8e 100644 --- a/Sources/SQLiteUndo/UndoEngine.swift +++ b/Sources/SQLiteUndo/UndoEngine.swift @@ -66,6 +66,9 @@ public struct UndoEngine: Sendable { /// /// - Parameter id: The barrier ID from `beginBarrier` public var cancelBarrier: @Sendable (_ id: UUID) throws -> Void + + /// Stream of events emitted after each undo/redo operation. + public var events: @Sendable () -> AsyncStream = { .finished } } /// Whether undo tracking is active. Default true; set false inside `withUndoDisabled`. @@ -224,6 +227,9 @@ extension UndoEngine: DependencyKey { }, cancelBarrier: { id in try coordinator.cancelBarrier(id) + }, + events: { + coordinator.events } ) } diff --git a/Sources/SQLiteUndo/UndoEvent.swift b/Sources/SQLiteUndo/UndoEvent.swift new file mode 100644 index 0000000..64af639 --- /dev/null +++ b/Sources/SQLiteUndo/UndoEvent.swift @@ -0,0 +1,39 @@ +import Foundation +import SQLiteData + +/// Identifies a row that was modified during an undo/redo operation. +public struct AffectedItem: Sendable, Hashable { + public let tableName: String + public let rowid: Int + + init(tableName: String, rowid: Int) { + self.tableName = tableName + self.rowid = rowid + } + + public init(table: T.Type, rowid: Int) { + self.tableName = T.tableName + self.rowid = rowid + } + + /// Extract a typed ID if this item belongs to the given table. + public func id( + as type: T.Type + ) -> T.ID? where T.ID: BinaryInteger { + tableName == T.tableName ? T.ID(rowid) : nil + } +} + +/// Emitted after each undo/redo operation with information about what changed. +public struct UndoEvent: Sendable, Equatable { + public enum Kind: Sendable, Equatable { case undo, redo } + public let kind: Kind + public let name: String + public let affectedItems: Set + + public init(kind: Kind, name: String, affectedItems: Set) { + self.kind = kind + self.name = name + self.affectedItems = affectedItems + } +} diff --git a/Sources/SQLiteUndo/UndoOperations.swift b/Sources/SQLiteUndo/UndoOperations.swift index 17c6933..51130fc 100644 --- a/Sources/SQLiteUndo/UndoOperations.swift +++ b/Sources/SQLiteUndo/UndoOperations.swift @@ -38,8 +38,13 @@ extension Database { /// The caller (UndoEngine) tracks the current seq range for each barrier and /// updates it after this method returns. /// - /// - Returns: The new seq range for the captured entries, or nil if no entries were executed. - func performUndoRedo(startSeq: Int, endSeq: Int) throws -> UndoCoordinator.SeqRange? { + struct UndoRedoResult { + var seqRange: UndoCoordinator.SeqRange + var affectedItems: Set + } + + /// - Returns: The new seq range and affected items, or nil if no entries were executed. + func performUndoRedo(startSeq: Int, endSeq: Int) throws -> UndoRedoResult? { logger.debug("Performing undo/redo: seq \(startSeq)...\(endSeq)") // Fetch entries to execute (in reverse order) @@ -54,6 +59,13 @@ extension Database { return nil } + // Collect affected items before deleting entries + let affectedItems = Set( + entries + .filter { $0.trackedRowid != 0 } + .map { AffectedItem(tableName: $0.tableName, rowid: $0.trackedRowid) } + ) + // Delete the entries try deleteUndoLogEntries(from: startSeq, to: endSeq) @@ -78,7 +90,7 @@ extension Database { // Reconcile duplicates from BEFORE triggers firing during replay try reconcileUndoLogEntries(from: newRange.startSeq, to: newRange.endSeq) logger.debug("New seq range: \(newRange.startSeq)...\(newRange.endSeq)") - return newRange + return UndoRedoResult(seqRange: newRange, affectedItems: affectedItems) } return nil diff --git a/Sources/SQLiteUndoTCA/UndoManaging.swift b/Sources/SQLiteUndoTCA/UndoManaging.swift index a012662..3fae979 100644 --- a/Sources/SQLiteUndoTCA/UndoManaging.swift +++ b/Sources/SQLiteUndoTCA/UndoManaging.swift @@ -38,6 +38,7 @@ public protocol UndoManageableAction { @CasePathable public enum UndoManagingAction: Sendable { case set(UndoManager?) + case event(UndoEvent) } /// A reducer that handles `UndoManaging` actions by setting the UndoManager on the UndoEngine. @@ -52,8 +53,12 @@ public enum UndoManagingAction: Sendable { /// } /// } /// ``` -public struct UndoManagingReducer: Reducer { +public struct UndoManagingReducer: Reducer { @Dependency(\.defaultUndoStack) var undoStack + @Dependency(\.defaultUndoEngine) var undoEngine + + private enum CancelID { case eventSubscription } + public init() {} public var body: some Reducer { Reduce { state, action in @@ -63,8 +68,16 @@ public struct UndoManagingReducer: Reducer switch undoAction { case .set(let manager): undoStack.setUndoManager(manager) + let events = undoEngine.events + return .run { send in + for await event in events() { + await send(.undoManager(.event(event))) + } + } + .cancellable(id: CancelID.eventSubscription, cancelInFlight: true) + case .event: + return .none } - return .none } } } diff --git a/Tests/SQLiteUndoTests/UndoEngineTests.swift b/Tests/SQLiteUndoTests/UndoEngineTests.swift index 91d6916..2b63f31 100644 --- a/Tests/SQLiteUndoTests/UndoEngineTests.swift +++ b/Tests/SQLiteUndoTests/UndoEngineTests.swift @@ -1,3 +1,4 @@ +import CustomDump import Dependencies import DependenciesTestSupport import Foundation @@ -662,6 +663,87 @@ enum UndoEngineTests { #expect(undoStack.currentState() == []) } } + @Suite + struct UndoEventTests { + + @Test + func eventEmittedOnUndo() async throws { + let (database, coordinator) = try makeTestDatabaseWithUndo() + + let barrierId = try coordinator.beginBarrier("Insert Item") + try await database.write { db in + try TestRecord.insert { TestRecord(id: 1, name: "Test") }.execute(db) + } + let barrier = try coordinator.endBarrier(barrierId)! + + try coordinator.performUndo(barrier: barrier) + + var iterator = coordinator.events.makeAsyncIterator() + let event = await iterator.next() + expectNoDifference(event, UndoEvent( + kind: .undo, + name: "Insert Item", + affectedItems: [AffectedItem(table: TestRecord.self, rowid: 1)] + )) + } + + @Test + func eventEmittedOnRedo() async throws { + let (database, coordinator) = try makeTestDatabaseWithUndo() + + let barrierId = try coordinator.beginBarrier("Insert Item") + try await database.write { db in + try TestRecord.insert { TestRecord(id: 1, name: "Test") }.execute(db) + } + let barrier = try coordinator.endBarrier(barrierId)! + + try coordinator.performUndo(barrier: barrier) + try coordinator.performRedo(barrier: barrier) + + var iterator = coordinator.events.makeAsyncIterator() + let undoEvent = await iterator.next() + #expect(undoEvent?.kind == .undo) + let redoEvent = await iterator.next() + expectNoDifference(redoEvent, UndoEvent( + kind: .redo, + name: "Insert Item", + affectedItems: [AffectedItem(table: TestRecord.self, rowid: 1)] + )) + } + + @Test + func affectedItemsForMultiRowBarrier() async throws { + let (database, coordinator) = try makeTestDatabaseWithUndo() + + let barrierId = try coordinator.beginBarrier("Batch Insert") + try await database.write { db in + for i in 1...3 { + try TestRecord.insert { TestRecord(id: i, name: "Item \(i)") }.execute(db) + } + } + let barrier = try coordinator.endBarrier(barrierId)! + + try coordinator.performUndo(barrier: barrier) + + var iterator = coordinator.events.makeAsyncIterator() + let event = await iterator.next() + expectNoDifference(event, UndoEvent( + kind: .undo, + name: "Batch Insert", + affectedItems: [ + AffectedItem(table: TestRecord.self, rowid: 1), + AffectedItem(table: TestRecord.self, rowid: 2), + AffectedItem(table: TestRecord.self, rowid: 3), + ] + )) + } + + @Test + func affectedItemIdAs() { + let item = AffectedItem(table: TestRecord.self, rowid: 42) + #expect(item.id(as: TestRecord.self) == 42) + } + } } @Table From 21274200975faa28091caf177b55c0887da3835d Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Wed, 11 Feb 2026 20:27:59 -0800 Subject: [PATCH 2/5] add demo for events --- Examples/UndoForMacOS/UndoForMacOSApp.swift | 75 ++++++++++++++++----- Sources/SQLiteUndoTCA/UndoManaging.swift | 10 +-- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/Examples/UndoForMacOS/UndoForMacOSApp.swift b/Examples/UndoForMacOS/UndoForMacOSApp.swift index bc9d1c3..80d06ae 100644 --- a/Examples/UndoForMacOS/UndoForMacOSApp.swift +++ b/Examples/UndoForMacOS/UndoForMacOSApp.swift @@ -32,6 +32,7 @@ struct DemoFeature { @ObservableState struct State { @FetchAll(DemoItem.all) var items: [DemoItem] + var eventLog: [UndoEvent] = [] } enum Action: UndoManageableAction { @@ -52,6 +53,9 @@ struct DemoFeature { UndoManagingReducer() Reduce { state, action in switch action { + case .undoManager(.event(let event)): + state.eventLog.append(event) + return .none case .undoManager: return .none @@ -152,22 +156,24 @@ struct DemoView: View { Button("Redo") { observableUndo.redo() }.disabled(!observableUndo.canRedo) } - List { - ForEach(store.items) { item in - HStack { - Text(item.name) - Spacer() - Text("Count: \(item.count)") - .foregroundStyle(.secondary) - Button("+") { - store.send(.incrementCount(item.id)) - } - .buttonStyle(.bordered) - Button("Delete") { - store.send(.deleteItem(item.id)) + HStack(alignment: .top, spacing: 0) { + List { + ForEach(store.items) { item in + HStack { + Text(item.name) + Spacer() + Text("Count: \(item.count)") + .foregroundStyle(.secondary) + Button("+") { + store.send(.incrementCount(item.id)) + } + .buttonStyle(.bordered) + Button("Delete") { + store.send(.deleteItem(item.id)) + } + .buttonStyle(.bordered) + .tint(.red) } - .buttonStyle(.bordered) - .tint(.red) } } } @@ -203,7 +209,44 @@ struct DemoView: View { } } .padding() - .frame(width: 400) + .frame(width: 600) + .safeAreaInset(edge: .trailing, content: { + VStack(alignment: .leading, spacing: 4) { + Text("Undo Events") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView { + VStack { + ForEach(Array(store.eventLog.enumerated().reversed()), id: \.offset) { _, event in + HStack(spacing: 6) { + Text(event.kind == .undo ? "undo" : "redo") + .font(.caption2.monospaced()) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(event.kind == .undo ? Color.orange.opacity(0.2) : Color.blue.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + VStack(alignment: .leading) { + Text(event.name) + .font(.caption) + Text( + event.affectedItems + .map { "\($0.tableName)#\($0.rowid)" } + .sorted() + .joined(separator: ", ") + ) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding() + .frame(width: 200) + .background(.background) + }) .setUndoManager(store: store) .onChange(of: undoManager, initial: true) { _, newValue in observableUndo.set(newValue) } } diff --git a/Sources/SQLiteUndoTCA/UndoManaging.swift b/Sources/SQLiteUndoTCA/UndoManaging.swift index 3fae979..18d32aa 100644 --- a/Sources/SQLiteUndoTCA/UndoManaging.swift +++ b/Sources/SQLiteUndoTCA/UndoManaging.swift @@ -103,12 +103,8 @@ struct SetUndoManagerModifier: ViewModifier let store: Store func body(content: Content) -> some View { - content - .onAppear { - store.send(.undoManager(.set(undoManager))) - } - .onChange(of: undoManager) { newValue in - store.send(.undoManager(.set(newValue))) - } + content.task(id: undoManager) { + await store.send(.undoManager(.set(undoManager))).finish() + } } } From 49895f94476902571ac1cf40bb98c79caf109f23 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Wed, 11 Feb 2026 20:28:10 -0800 Subject: [PATCH 3/5] bump to macos 12 for task modifier --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 16b8fa2..6c08ede 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "sqlite-undo", platforms: [ .iOS(.v13), - .macOS(.v11), + .macOS(.v12), ], products: [ .library(name: "SQLiteUndo", targets: ["SQLiteUndo"]), From 4d3d6cd3c984220bcdf07962c9b0eaade21eca3f Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Wed, 11 Feb 2026 21:19:35 -0800 Subject: [PATCH 4/5] improve api for getting ids from undo event --- Examples/UndoForMacOS/UndoForMacOSApp.swift | 74 ++++++++++++--------- Sources/SQLiteUndo/UndoEvent.swift | 27 +++++++- Tests/SQLiteUndoTests/UndoEngineTests.swift | 19 ++++++ 3 files changed, 88 insertions(+), 32 deletions(-) diff --git a/Examples/UndoForMacOS/UndoForMacOSApp.swift b/Examples/UndoForMacOS/UndoForMacOSApp.swift index 80d06ae..8546116 100644 --- a/Examples/UndoForMacOS/UndoForMacOSApp.swift +++ b/Examples/UndoForMacOS/UndoForMacOSApp.swift @@ -54,6 +54,13 @@ struct DemoFeature { Reduce { state, action in switch action { case .undoManager(.event(let event)): + if let ids = event.ids(for: DemoItem.self) { + print( + event.kind, + event.name.debugDescription, + ids.map { $0.formatted() } + ) + } state.eventLog.append(event) return .none case .undoManager: @@ -210,43 +217,48 @@ struct DemoView: View { } .padding() .frame(width: 600) - .safeAreaInset(edge: .trailing, content: { - VStack(alignment: .leading, spacing: 4) { - Text("Undo Events") - .font(.caption) - .foregroundStyle(.secondary) - ScrollView { - VStack { - ForEach(Array(store.eventLog.enumerated().reversed()), id: \.offset) { _, event in - HStack(spacing: 6) { - Text(event.kind == .undo ? "undo" : "redo") - .font(.caption2.monospaced()) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(event.kind == .undo ? Color.orange.opacity(0.2) : Color.blue.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 3)) - VStack(alignment: .leading) { - Text(event.name) - .font(.caption) - Text( - event.affectedItems - .map { "\($0.tableName)#\($0.rowid)" } - .sorted() - .joined(separator: ", ") - ) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) + .safeAreaInset( + edge: .trailing, + content: { + VStack(alignment: .leading, spacing: 4) { + Text("Undo Events") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView { + VStack { + ForEach(Array(store.eventLog.enumerated().reversed()), id: \.offset) { _, event in + HStack(spacing: 6) { + Text(event.kind == .undo ? "undo" : "redo") + .font(.caption2.monospaced()) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + event.kind == .undo ? Color.orange.opacity(0.2) : Color.blue.opacity(0.2) + ) + .clipShape(RoundedRectangle(cornerRadius: 3)) + VStack(alignment: .leading) { + Text(event.name) + .font(.caption) + Text( + event.affectedItems + .map { "\($0.tableName)#\($0.rowid)" } + .sorted() + .joined(separator: ", ") + ) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } } } } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) } + .padding() + .frame(width: 200) + .background(.background) } - .padding() - .frame(width: 200) - .background(.background) - }) + ) .setUndoManager(store: store) .onChange(of: undoManager, initial: true) { _, newValue in observableUndo.set(newValue) } } diff --git a/Sources/SQLiteUndo/UndoEvent.swift b/Sources/SQLiteUndo/UndoEvent.swift index 64af639..ec2c7c6 100644 --- a/Sources/SQLiteUndo/UndoEvent.swift +++ b/Sources/SQLiteUndo/UndoEvent.swift @@ -26,7 +26,9 @@ public struct AffectedItem: Sendable, Hashable { /// Emitted after each undo/redo operation with information about what changed. public struct UndoEvent: Sendable, Equatable { - public enum Kind: Sendable, Equatable { case undo, redo } + public enum Kind: Sendable, Equatable { + case undo, redo + } public let kind: Kind public let name: String public let affectedItems: Set @@ -36,4 +38,27 @@ public struct UndoEvent: Sendable, Equatable { self.name = name self.affectedItems = affectedItems } + + /// Returns the typed IDs of affected rows for the given table, or nil if none matched. + /// + /// ```swift + /// if let itemIds = event.ids(for: Article.self) { + /// // respond undo/redo of Article + /// } + /// ``` + public func ids( + for type: T.Type + ) -> Set? where T.ID: BinaryInteger { + let ids = Set(affectedItems.compactMap { $0.id(as: type) }) + return ids.isEmpty ? nil : ids + } +} + +extension UndoEvent.Kind: CustomStringConvertible { + public var description: String { + switch self { + case .undo: return "Undo" + case .redo: return "Redo" + } + } } diff --git a/Tests/SQLiteUndoTests/UndoEngineTests.swift b/Tests/SQLiteUndoTests/UndoEngineTests.swift index 2b63f31..9865a65 100644 --- a/Tests/SQLiteUndoTests/UndoEngineTests.swift +++ b/Tests/SQLiteUndoTests/UndoEngineTests.swift @@ -743,6 +743,20 @@ enum UndoEngineTests { let item = AffectedItem(table: TestRecord.self, rowid: 42) #expect(item.id(as: TestRecord.self) == 42) } + + @Test + func eventIdsForTable() { + let event = UndoEvent( + kind: .undo, + name: "Test", + affectedItems: [ + AffectedItem(table: TestRecord.self, rowid: 1), + AffectedItem(table: TestRecord.self, rowid: 3), + ] + ) + expectNoDifference(event.ids(for: TestRecord.self), [1, 3]) + #expect(event.ids(for: UntrackedRecord.self) == nil) + } } } @@ -753,6 +767,11 @@ private struct TestRecord: Identifiable { var value: Int? } +@Table +private struct UntrackedRecord: Identifiable { + @Column(primaryKey: true) var id: Int +} + private func makeTestDatabase() throws -> any DatabaseWriter { let database = try DatabaseQueue(configuration: Configuration()) try database.write { db in From 970f17058865130d7f35997b1c538789ef2a31cc Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Wed, 11 Feb 2026 21:33:29 -0800 Subject: [PATCH 5/5] update readme for events --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 9bd810a..4d5cdfd 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,23 @@ try database.write { db in try undoEngine.endBarrier(barrierId) ``` +### Undo events + +After each undo/redo, `UndoEngine` emits an `UndoEvent` with the affected table rows. Use this to drive UI responses like scrolling to a restored item or switching views. + +```swift +for await event in undoEngine.events() { + if let articleIds = event.ids(for: Article.self) { + // scroll to restored articles + } + if let authorIds = event.ids(for: Author.self) { + // handle affected authors + } +} +``` + +`ids(for:)` returns `nil` when no rows of that table were affected, so `if let` naturally gates your response logic. + ## ComposableArchitecture/SwiftUI Integration ```swift @@ -134,6 +151,11 @@ struct MyFeature { UndoManagingReducer() Reduce { state, action in switch action { + case .undoManager(.event(let event)): // ✅ respond to undo/redo events + if let articleIds = event.ids(for: Article.self) { + // navigate to affected articles + } + return .none case .undoManager: return .none case .setRating(let rating):