From 02e486d5a55e9e048afe0c62bc34ff87ec8adcc4 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Sat, 21 Feb 2026 20:45:36 +0100 Subject: [PATCH 01/16] Support for Undo --- .../xcshareddata/swiftpm/Package.resolved | 6 +- Examples/Reminders/README.md | 4 + Examples/Reminders/ReminderForm.swift | 3 +- Examples/Reminders/ReminderRow.swift | 10 +- Examples/Reminders/RemindersApp.swift | 101 ++- Examples/Reminders/RemindersDetail.swift | 5 +- Examples/Reminders/RemindersListForm.swift | 4 +- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 57 +- Examples/Reminders/Schema.swift | 14 +- Examples/Reminders/SearchReminders.swift | 2 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 4 +- Examples/Reminders/UndoToolbarButtons.swift | 117 +++ .../CloudKit/Internal/UserDatabase.swift | 43 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 72 +- .../SQLiteData/Undo/DatabaseWriter+Undo.swift | 29 + .../SQLiteData/Undo/DefaultUndoManager.swift | 30 + .../SQLiteData/Undo/Internal/UndoEntry.swift | 11 + .../Undo/Internal/UndoFunctions.swift | 21 + .../SQLiteData/Undo/Internal/UndoLog.swift | 17 + .../Undo/Internal/UndoTriggers.swift | 195 +++++ .../SQLiteData/Undo/SQLiteUndoManager.swift | 2 + Sources/SQLiteData/Undo/UndoAction.swift | 7 + Sources/SQLiteData/Undo/UndoEvent.swift | 47 ++ Sources/SQLiteData/Undo/UndoGroup.swift | 30 + Sources/SQLiteData/Undo/UndoManager.swift | 666 ++++++++++++++++ .../SQLiteData/Undo/UndoManagerDelegate.swift | 34 + Sources/SQLiteData/Undo/WithoutUndo.swift | 23 + .../UndoTests/UndoManagerTests.swift | 754 ++++++++++++++++++ 30 files changed, 2258 insertions(+), 54 deletions(-) create mode 100644 Examples/Reminders/UndoToolbarButtons.swift create mode 100644 Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift create mode 100644 Sources/SQLiteData/Undo/DefaultUndoManager.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoEntry.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoFunctions.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoLog.swift create mode 100644 Sources/SQLiteData/Undo/Internal/UndoTriggers.swift create mode 100644 Sources/SQLiteData/Undo/SQLiteUndoManager.swift create mode 100644 Sources/SQLiteData/Undo/UndoAction.swift create mode 100644 Sources/SQLiteData/Undo/UndoEvent.swift create mode 100644 Sources/SQLiteData/Undo/UndoGroup.swift create mode 100644 Sources/SQLiteData/Undo/UndoManager.swift create mode 100644 Sources/SQLiteData/Undo/UndoManagerDelegate.swift create mode 100644 Sources/SQLiteData/Undo/WithoutUndo.swift create mode 100644 Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e5aa49c9..d2fae421 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", + "originHash" : "e7966629f2319c6882643a1f8848263b5757a7fd8747840f570db1f7f8a83c6b", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "xcode-26-4", - "revision" : "31dd6609d8398016011b418374d82fb6f716c944" + "revision" : "20db4a2a446f51e67e1207d54a23ad0a03471a7b", + "version" : "0.31.0" } }, { diff --git a/Examples/Reminders/README.md b/Examples/Reminders/README.md index 9ee145e4..60bd5554 100644 --- a/Examples/Reminders/README.md +++ b/Examples/Reminders/README.md @@ -4,6 +4,10 @@ A rebuild of many of the features from Apple's [Reminders app][reminders-app-sto for reminders, lists and tags in a SQLite database, and uses foreign keys to express one-to-many and many-to-many relationships between the entities. +The sample configures a default undo manager so local and synced changes can be undone/redone from +the screen menu using Undo/Redo entries that trigger immediately. It also binds to Apple's +`UndoManager` so system undo gestures (including shake to undo) work with the same stack. + It also demonstrates how to perform very advanced queries in SQLite that would be impossible in SwiftData, such as using SQLite's `group_concat` function to fetch all reminders along with a comma-separated list of all of its tags. SQLite is an incredibly powerful language, and one should diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index a41e28e0..8e34af64 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -169,7 +169,8 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup(reminder.id == nil ? "Create reminder" : "Edit reminder") { + db in let reminderID = try Reminder.upsert { reminder } .returning(\.id) .fetchOne(db)! diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index c112eaa3..9e3b3567 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -76,14 +76,16 @@ struct ReminderRow: View { .swipeActions { Button("Delete", role: .destructive) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete reminder") { db in try Reminder.delete(reminder).execute(db) } } } Button(reminder.isFlagged ? "Unflag" : "Flag") { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup( + reminder.isFlagged ? "Unflag reminder" : "Flag reminder" + ) { db in try Reminder .find(reminder.id) .update { $0.isFlagged.toggle() } @@ -106,7 +108,9 @@ struct ReminderRow: View { private func completeButtonTapped() { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup( + reminder.isCompleted ? "Mark reminder incomplete" : "Mark reminder complete" + ) { db in try Reminder .find(reminder.id) .update { $0.toggleStatus() } diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 93b7b845..0b83d240 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -14,11 +14,15 @@ struct RemindersApp: App { static let model = RemindersListsModel() @State var syncEngineDelegate = RemindersSyncEngineDelegate() + @State var undoManagerDelegate = RemindersUndoManagerDelegate() init() { if context == .live { try! prepareDependencies { - try $0.bootstrapDatabase(syncEngineDelegate: syncEngineDelegate) + try $0.bootstrapDatabase( + syncEngineDelegate: syncEngineDelegate, + undoManagerDelegate: undoManagerDelegate + ) } } } @@ -29,6 +33,7 @@ struct RemindersApp: App { NavigationStack { RemindersListsView(model: Self.model) } + .bindSQLiteUndoManagerToSystemUndo() .alert( "Reset local data?", isPresented: $syncEngineDelegate.isDeleteLocalDataAlertPresented @@ -46,6 +51,18 @@ struct RemindersApp: App { """ ) } + .alert(item: $undoManagerDelegate.confirmationRequest) { request in + Alert( + title: Text(request.title), + message: Text(request.message), + primaryButton: .destructive(Text(request.confirmButtonTitle)) { + undoManagerDelegate.respondToConfirmation(confirmed: true) + }, + secondaryButton: .cancel { + undoManagerDelegate.respondToConfirmation(confirmed: false) + } + ) + } } } } @@ -70,6 +87,88 @@ class RemindersSyncEngineDelegate: SyncEngineDelegate { } } +@MainActor +@Observable +final class RemindersUndoManagerDelegate: UndoManagerDelegate { + struct ConfirmationRequest: Identifiable { + let action: UndoAction + let group: UndoGroup + var id: UUID { group.id } + var title: String { + switch action { + case .undo: "Undo \"\(group.description)\"?" + case .redo: "Redo \"\(group.description)\"?" + } + } + var message: String { + "This change came from \(originDescription). Are you sure you want to continue?" + } + var confirmButtonTitle: String { + switch action { + case .undo: "Undo" + case .redo: "Redo" + } + } + + private var originDescription: String { + var parts: [String] = [] + if group.deviceID != SQLiteUndoManager.defaultDeviceID { + if group.deviceID == "sqlitedata-sync" { + parts.append("another device") + } else { + parts.append("device \(group.deviceID)") + } + } + if + let userRecordName = group.userRecordName + { + parts.append("user \(userRecordName)") + } + return parts.isEmpty ? "this device" : parts.joined(separator: " and ") + } + } + + var confirmationRequest: ConfirmationRequest? + private var confirmationContinuation: CheckedContinuation? + + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + guard shouldConfirm(for: group) else { + try await performAction() + return + } + if await requestConfirmation(action: action, group: group) { + try await performAction() + } + } + + func respondToConfirmation(confirmed: Bool) { + confirmationContinuation?.resume(returning: confirmed) + confirmationContinuation = nil + confirmationRequest = nil + } + + private func shouldConfirm(for group: UndoGroup) -> Bool { + let isOtherDevice = group.deviceID != SQLiteUndoManager.defaultDeviceID + let isOtherUser = group.userRecordName != nil + return isOtherDevice || isOtherUser + } + + private func requestConfirmation(action: UndoAction, group: UndoGroup) async -> Bool { + if confirmationContinuation != nil { + respondToConfirmation(confirmed: false) + } + return await withCheckedContinuation { continuation in + confirmationContinuation = continuation + confirmationRequest = ConfirmationRequest(action: action, group: group) + } + } +} + class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application( _ application: UIApplication, diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 164443b7..a8a095a0 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -50,7 +50,7 @@ class RemindersDetailModel: HashableObject { func move(from source: IndexSet, to destination: Int) async { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Reorder reminders") { db in var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder @@ -252,6 +252,9 @@ struct RemindersDetailView: View { } } Menu { + UndoMenuItems() + .tint(model.detailType.color) + Divider() Group { Menu { ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 98d4a022..44dc7df8 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -77,7 +77,9 @@ struct RemindersListForm: View { Button("Save") { Task { [remindersList, coverImageData] in await withErrorReporting { - try await database.write { db in + try await database.writeWithUndoGroup( + remindersList.id == nil ? "Create list" : "Edit list" + ) { db in let remindersListID = try RemindersList .upsert { remindersList } diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index c2778bf8..8f4e6cbf 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -35,7 +35,7 @@ struct RemindersListRow: View { .swipeActions { Button { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete list") { db in try RemindersList.delete(remindersList) .execute(db) } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 11080ec3..4a3d9ece 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -55,6 +55,10 @@ class RemindersListsModel { @ObservationIgnored @Dependency(\.defaultDatabase) private var database + @ObservationIgnored + @Dependency(\.defaultUndoManager) private var undoManager + @ObservationIgnored + private var undoEventsTask: Task? func statTapped(_ detailType: RemindersDetailModel.DetailType) { destination = .detail(RemindersDetailModel(detailType: detailType)) @@ -81,7 +85,7 @@ class RemindersListsModel { func deleteTags(atOffsets offsets: IndexSet) { withErrorReporting { let tagTitles = offsets.map { tags[$0].title } - try database.write { db in + try database.writeWithUndoGroup("Delete tags") { db in try Tag .where { $0.title.in(tagTitles) } .delete() @@ -91,6 +95,7 @@ class RemindersListsModel { } func onAppear() { + observeUndoEventsIfNeeded() withErrorReporting { try Tips.configure() } @@ -121,7 +126,7 @@ class RemindersListsModel { func move(from source: IndexSet, to destination: Int) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Reorder lists") { db in var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) try RemindersList @@ -149,6 +154,36 @@ class RemindersListsModel { } #endif + deinit { + undoEventsTask?.cancel() + } + + private func observeUndoEventsIfNeeded() { + guard undoEventsTask == nil, let undoManager else { return } + undoEventsTask = Task { [weak self] in + guard let self else { return } + for await event in undoManager.events { + await self.handleUndoEvent(event) + } + } + } + + private func handleUndoEvent(_ event: UndoEvent) async { + guard event.kind == .undo else { return } + guard event.affectedRows.contains(where: { $0.tableName == RemindersList.tableName }) else { return } + guard case let .detail(detailModel)? = destination else { return } + guard case let .remindersList(remindersList) = detailModel.detailType else { return } + + await withErrorReporting { + let isStillPresent = try await database.read { db in + try RemindersList.find(remindersList.id).fetchOne(db) != nil + } + if !isStillPresent { + destination = nil + } + } + } + @CasePathable enum Destination { case detail(RemindersDetailModel) @@ -312,9 +347,11 @@ struct RemindersListsView: View { } .listStyle(.insetGrouped) .toolbar { - #if DEBUG - ToolbarItem(placement: .automatic) { - Menu { + ToolbarItem(placement: .primaryAction) { + Menu { + UndoMenuItems() + #if DEBUG + Divider() Button { model.seedDatabaseButtonTapped() } label: { @@ -335,12 +372,12 @@ struct RemindersListsView: View { Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing") Image(systemName: syncEngine.isRunning ? "stop" : "play") } - } label: { - Image(systemName: "ellipsis.circle") - } - .popoverTip(model.seedDatabaseTip) + #endif + } label: { + Image(systemName: "ellipsis.circle") } - #endif + .popoverTip(model.seedDatabaseTip) + } ToolbarItem(placement: .bottomBar) { HStack { Button { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 0247c9ea..763e8e36 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -116,8 +116,20 @@ struct ReminderText: FTS5 { } extension DependencyValues { - mutating func bootstrapDatabase(syncEngineDelegate: (any SyncEngineDelegate)? = nil) throws { + mutating func bootstrapDatabase( + syncEngineDelegate: (any SyncEngineDelegate)? = nil, + undoManagerDelegate: (any UndoManagerDelegate)? = nil + ) throws { defaultDatabase = try Reminders.appDatabase() + defaultUndoManager = try UndoManager( + for: defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self, + delegate: undoManagerDelegate + ) defaultSyncEngine = try SyncEngine( for: defaultDatabase, tables: RemindersList.self, diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index be22878f..0fce00c6 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -64,7 +64,7 @@ class SearchRemindersModel { func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Clear completed reminders") { db in try Reminder .where { $0.isCompleted diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index 37ae1e0b..32444e5a 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -18,7 +18,7 @@ struct TagRow: View { .swipeActions { Button { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete tag") { db in try Tag.delete(tag) .execute(db) } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index ae97a2b4..989c52dc 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -80,7 +80,7 @@ struct TagsView: View { func deleteButtonTapped(tag: Tag) { withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup("Delete tag") { db in try Tag.find(tag.title).delete().execute(db) } } @@ -95,7 +95,7 @@ struct TagsView: View { defer { tagTitle = "" } let tag = Tag(title: tagTitle) withErrorReporting { - try database.write { db in + try database.writeWithUndoGroup(editingTag == nil ? "Create tag" : "Edit tag") { db in if let existingTagTitle = editingTag?.title { selectedTags.removeAll(where: { $0.title == existingTagTitle }) try Tag diff --git a/Examples/Reminders/UndoToolbarButtons.swift b/Examples/Reminders/UndoToolbarButtons.swift new file mode 100644 index 00000000..4ad5ee0f --- /dev/null +++ b/Examples/Reminders/UndoToolbarButtons.swift @@ -0,0 +1,117 @@ +import SQLiteData +import SwiftUI + +struct UndoMenuItems: View { + @Dependency(\.defaultUndoManager) private var undoManager + + var body: some View { + if let undoManager { + ControlGroup { + Button { + performUndo() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .disabled(!undoManager.canUndo) + + Button { + performRedo() + } label: { + Label("Redo", systemImage: "arrow.uturn.forward") + } + .disabled(!undoManager.canRedo) + + } + .controlGroupStyle(.menu) + + if !undoManager.undoStack.isEmpty { + Menu { + ForEach(undoManager.undoStack) { group in + Button("Undo \(group.description)") { + performUndo(to: group) + } + } + } label: { + Label("Undo", systemImage: "arrow.uturn.backward.square") + } + } + + if !undoManager.redoStack.isEmpty { + Menu { + ForEach(undoManager.redoStack) { group in + Button("Redo \(group.description)") { + performRedo(to: group) + } + } + } label: { + Label("Redo", systemImage: "arrow.uturn.forward.square") + } + } + } + } + + private func performUndo(to group: UndoGroup? = nil) { + perform(.undo, to: group) + } + + private func performRedo(to group: UndoGroup? = nil) { + perform(.redo, to: group) + } + + private func perform(_ action: UndoAction, to targetGroup: UndoGroup?) { + guard let undoManager else { return } + Task { + await withErrorReporting { + let stack: [UndoGroup] + switch action { + case .undo: stack = undoManager.undoStack + case .redo: stack = undoManager.redoStack + } + let count = + targetGroup + .flatMap { target in stack.firstIndex { $0.id == target.id }.map { $0 + 1 } } + ?? 1 + guard count > 0 else { return } + for _ in 0.. some View { + content + .task(id: foundationUndoManager.map(ObjectIdentifier.init)) { + sqliteUndoManager?.bind(to: foundationUndoManager) + } + } +} + +extension View { + func bindSQLiteUndoManagerToSystemUndo() -> some View { + modifier(BindSQLiteUndoManagerToSystemUndo()) + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift index b0fb5edc..e1950a5c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift @@ -1,4 +1,5 @@ #if canImport(CloudKit) + import CloudKit import Dependencies package struct UserDatabase { @@ -18,7 +19,22 @@ package func write( _ updates: @Sendable (Database) throws -> T ) async throws -> T { - try await database.write { db in + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: database) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: database) + if let undoManager { + return try await undoManager.withGroup( + "Sync iCloud changes", + deviceID: UndoManager.syncDeviceID, + userRecordName: syncUndoUserRecordName + ) { db in + try $_isSynchronizingChanges.withValue(true) { + try updates(db) + } + } + } + return try await database.write { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) } @@ -37,7 +53,22 @@ package func write( _ updates: (Database) throws -> T ) throws -> T { - try database.write { db in + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: database) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: database) + if let undoManager { + return try undoManager.withGroup( + "Sync iCloud changes", + deviceID: UndoManager.syncDeviceID, + userRecordName: syncUndoUserRecordName + ) { db in + try $_isSynchronizingChanges.withValue(true) { + try updates(db) + } + } + } + return try database.write { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) } @@ -52,5 +83,13 @@ try updates(db) } } + + private var syncUndoUserRecordName: String? { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return _currentZoneID?.ownerName + } else { + return nil + } + } } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 871c7999..1a9a49bb 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1481,21 +1481,30 @@ if let table = tablesByName[recordType] { func open(_: some SynchronizableTable) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - try T - .where { - #sql("\($0.primaryKey)").in( - SyncMetadata.findAll(recordIDs) - .select(\.recordPrimaryKey) - ) - } - .delete() - .execute(db) + let write = { + try await self.userDatabase.write { db in + try T + .where { + #sql("\($0.primaryKey)").in( + SyncMetadata.findAll(recordIDs) + .select(\.recordPrimaryKey) + ) + } + .delete() + .execute(db) - try UnsyncedRecordID - .findAll(recordIDs) - .delete() - .execute(db) + try UnsyncedRecordID + .findAll(recordIDs) + .delete() + .execute(db) + } + } + if let zoneID = recordIDs.first?.zoneID { + try await $_currentZoneID.withValue(zoneID) { + try await write() + } + } else { + try await write() } } } @@ -1585,19 +1594,28 @@ } let shares: [ShareOrReference] = await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - var shares: [ShareOrReference] = [] - for record in modifications { - if let share = record as? CKShare { - shares.append(.share(share)) - } else { - upsertFromServerRecord(record, db: db) - if let shareReference = record.share { - shares.append(.reference(shareReference)) + let write = { + try await self.userDatabase.write { db in + var shares: [ShareOrReference] = [] + for record in modifications { + if let share = record as? CKShare { + shares.append(.share(share)) + } else { + self.upsertFromServerRecord(record, db: db) + if let shareReference = record.share { + shares.append(.reference(shareReference)) + } } } + return shares + } + } + if let zoneID = modifications.first?.recordID.zoneID { + return try await $_currentZoneID.withValue(zoneID) { + try await write() } - return shares + } else { + return try await write() } } ?? [] @@ -1892,8 +1910,10 @@ force: Bool = false ) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - upsertFromServerRecord(serverRecord, force: force, db: db) + try await $_currentZoneID.withValue(serverRecord.recordID.zoneID) { + try await userDatabase.write { db in + upsertFromServerRecord(serverRecord, force: force, db: db) + } } } } diff --git a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift new file mode 100644 index 00000000..4735a3ee --- /dev/null +++ b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift @@ -0,0 +1,29 @@ +public extension DatabaseWriter { + func writeWithUndoGroup( + _ description: String, + _ updates: (Database) throws -> T + ) throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try undoManager.withGroup(description, updates) + } + return try write(updates) + } + + func writeWithUndoGroup( + _ description: String, + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try await undoManager.withGroup(description, updates) + } + return try await write(updates) + } +} diff --git a/Sources/SQLiteData/Undo/DefaultUndoManager.swift b/Sources/SQLiteData/Undo/DefaultUndoManager.swift new file mode 100644 index 00000000..1937520a --- /dev/null +++ b/Sources/SQLiteData/Undo/DefaultUndoManager.swift @@ -0,0 +1,30 @@ +import Dependencies + +extension DependencyValues { + /// The default SQLiteData undo manager used by integrations when available. + /// + /// Configure this as early as possible in your app's lifetime, for example with + /// `prepareDependencies`: + /// + /// ```swift + /// prepareDependencies { + /// $0.defaultDatabase = try! appDatabase() + /// $0.defaultUndoManager = try! UndoManager( + /// for: $0.defaultDatabase, + /// tables: Item.self + /// ) + /// } + /// ``` + /// + /// If no default undo manager is set, SQLiteData continues to work without undo support. + public var defaultUndoManager: UndoManager? { + get { self[DefaultUndoManagerKey.self] } + set { self[DefaultUndoManagerKey.self] = newValue } + } + + private enum DefaultUndoManagerKey: DependencyKey { + static let liveValue: UndoManager? = nil + static let previewValue: UndoManager? = nil + static let testValue: UndoManager? = nil + } +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoEntry.swift b/Sources/SQLiteData/Undo/Internal/UndoEntry.swift new file mode 100644 index 00000000..cc0a0f5c --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoEntry.swift @@ -0,0 +1,11 @@ +import Foundation + +/// An in-memory record of a single undo or redo group's position in the undo log. +package struct UndoEntry: Sendable { + /// The lowest `seq` value written to `sqlitedata_undo_log` by this group. + package let begin: Int + /// The highest `seq` value written to `sqlitedata_undo_log` by this group. + package let end: Int + /// The public metadata associated with this group. + package let group: UndoGroup +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoFunctions.swift b/Sources/SQLiteData/Undo/Internal/UndoFunctions.swift new file mode 100644 index 00000000..f8715bf5 --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoFunctions.swift @@ -0,0 +1,21 @@ +import StructuredQueriesCore + +/// A task-local flag set to `true` while the undo manager is executing inverse SQL so that +/// the undo triggers do not record the inverse operations as new undo entries. +@TaskLocal package var _isUndoingOrRedoing = false +@TaskLocal package var _isUndoRecordingDisabled = false + +@DatabaseFunction("sqlitedata_undo_isReplaying") +package func _isReplaying() -> Bool { + _isUndoingOrRedoing +} + +/// A SQLite scalar function registered on every database connection managed by ``UndoManager``. +/// +/// Triggers use `WHEN sqlitedata_undo_shouldRecord()` to decide whether to record an inverse +/// SQL statement. Returns `false` only when recording is explicitly disabled. +@DatabaseFunction("sqlitedata_undo_shouldRecord") +package func _shouldRecord() -> Bool { + if _isUndoRecordingDisabled { return false } + return true +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoLog.swift b/Sources/SQLiteData/Undo/Internal/UndoLog.swift new file mode 100644 index 00000000..bd077da6 --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoLog.swift @@ -0,0 +1,17 @@ +import StructuredQueriesCore + +/// A row in the temporary `sqlitedata_undo_log` table, which stores inverse SQL statements +/// generated by undo triggers. +@Table("sqlitedata_undo_log") +package struct UndoLog { + package static var schemaName: String? { "temp" } + + /// Auto-incremented sequence number (the SQLite rowid alias). + package let seq: Int + /// The table name whose row change produced this inverse SQL entry. + package let tableName: String + /// The affected rowid in `tableName`. + package let trackedRowID: Int + /// A SQL statement that inverts the original change. + package let sql: String +} diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift new file mode 100644 index 00000000..cf2faaf9 --- /dev/null +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -0,0 +1,195 @@ +import Foundation +import GRDB + +// MARK: - Column info + +/// Reads writable column names for `tableName`. +/// +/// Prefer `pragma_table_xinfo` so hidden/generated columns can be filtered with `hidden = 0`. +/// Fall back to `pragma_table_info` on older SQLite builds where `table_xinfo` is unavailable. +package func undoColumnNames(for tableName: String, in db: Database) throws -> [String] { + // Use pragma table-valued functions. The table name is embedded as a quoted + // SQL identifier, not as a bound parameter, because some SQLite versions do not support bound + // parameters in table-valued-function arguments. + let tableLiteral = "'" + tableName.replacingOccurrences(of: "'", with: "''") + "'" + do { + return try String.fetchAll( + db, + sql: """ + SELECT name FROM pragma_table_xinfo(\(tableLiteral)) + WHERE hidden = 0 + ORDER BY cid + """ + ) + } catch { + return try String.fetchAll( + db, + sql: """ + SELECT name FROM pragma_table_info(\(tableLiteral)) + ORDER BY cid + """ + ) + } +} + +// MARK: - Undo log table + +/// The DDL that creates the per-connection temporary undo log table. +package let undoLogTableSQL = """ + CREATE TEMP TABLE IF NOT EXISTS "sqlitedata_undo_log" ( + "seq" INTEGER PRIMARY KEY AUTOINCREMENT, + "tableName" TEXT NOT NULL, + "trackedRowID" INTEGER NOT NULL DEFAULT 0, + "sql" TEXT NOT NULL + ) + """ + +// MARK: - Trigger SQL + +/// Generates the three undo triggers for a single table. +/// +/// - Parameters: +/// - tableName: The name of the user table to observe. +/// - columns: The writable column names obtained from `undoColumnNames(for:in:)`. +/// - Returns: Three `CREATE TEMP TRIGGER` statements (insert, update, delete). +package func undoTriggerSQL(for tableName: String, columns: [String]) -> [String] { + let qt: String = undoDoubleQuotedIdentifier(tableName) + let logTable = "\"sqlitedata_undo_log\"" + let whenClause = "WHEN sqlitedata_undo_shouldRecord()" + let triggerPrefix = "_sqlitedata_undo_" + + // INSERT → log a DELETE that removes the new row + let insertTrigger = """ + CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)insert_\(tableName)")) + AFTER INSERT ON \(qt) + \(whenClause) + BEGIN + INSERT INTO \(logTable) VALUES( + NULL, + '\(tableName)', + NEW.rowid, + 'DELETE FROM \(qt) WHERE rowid='||NEW.rowid + ); + END + """ + + // UPDATE → log an UPDATE that restores all old column values + // Only fire when at least one column actually changed. + let changedCondition: String = columns + .map { col -> String in + let qc: String = undoDoubleQuotedIdentifier(col) + return "OLD.\(qc) IS NOT NEW.\(qc)" + } + .joined(separator: " OR ") + let setClause: String = columns + .map { col -> String in + let qc: String = undoDoubleQuotedIdentifier(col) + return "\(qc)='||quote(OLD.\(qc))||'" + } + .joined(separator: ",") + let updateTrigger = """ + CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)update_\(tableName)")) + BEFORE UPDATE ON \(qt) + WHEN \(whenClause.dropFirst("WHEN ".count)) AND (\(changedCondition)) + BEGIN + INSERT INTO \(logTable) VALUES( + NULL, + '\(tableName)', + OLD.rowid, + 'UPDATE \(qt) SET \(setClause) WHERE rowid='||OLD.rowid + ); + END + """ + + // DELETE → log an INSERT that restores the deleted row + let colList: String = columns.map { undoDoubleQuotedIdentifier($0) }.joined(separator: ",") + let valList: String = columns + .map { col -> String in "'||quote(OLD.\(undoDoubleQuotedIdentifier(col)))||'" } + .joined(separator: ",") + let deleteTrigger = """ + CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)delete_\(tableName)")) + BEFORE DELETE ON \(qt) + \(whenClause) + BEGIN + INSERT INTO \(logTable) VALUES( + NULL, + '\(tableName)', + OLD.rowid, + 'INSERT INTO \(qt)(rowid,\(colList)) VALUES('||OLD.rowid||',\(valList))' + ); + END + """ + + return [insertTrigger, updateTrigger, deleteTrigger] +} + +/// Drop SQL for the three undo triggers of a table. +package func undoTriggerDropSQL(for tableName: String) -> [String] { + let prefix = "_sqlitedata_undo_" + return ["insert", "update", "delete"].map { kind in + "DROP TEMP TRIGGER IF EXISTS \(undoDoubleQuotedIdentifier("\(prefix)\(kind)_\(tableName)"))" + } +} + +// MARK: - Undo log analysis + +package func undoModifiedTableNames(in db: Database, from startSeq: Int, to endSeq: Int) throws -> Set { + Set( + try String.fetchAll( + db, + sql: """ + SELECT DISTINCT "tableName" + FROM "sqlitedata_undo_log" + WHERE "seq" >= ? AND "seq" <= ? + """, + arguments: [startSeq, endSeq] + ) + ) +} + +package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq: Int) throws { + let entries = try UndoLog + .where { $0.seq >= startSeq && $0.seq <= endSeq } + .order { $0.seq.asc() } + .fetchAll(db) + + var grouped = [String: [UndoLog]]() + for entry in entries where entry.trackedRowID != 0 { + grouped["\(entry.tableName):\(entry.trackedRowID)", default: []].append(entry) + } + + var seqsToDelete: [Int] = [] + for (_, group) in grouped where group.count > 1 { + let first = group[0] + let last = group[group.count - 1] + + let firstIsDeleteReverse = first.sql.uppercased().hasPrefix("DELETE FROM") + let lastIsInsertReverse = last.sql.uppercased().hasPrefix("INSERT INTO") + + if firstIsDeleteReverse && lastIsInsertReverse { + seqsToDelete.append(contentsOf: group.map(\.seq)) + continue + } + + if firstIsDeleteReverse { + seqsToDelete.append(contentsOf: group.dropFirst().map(\.seq)) + continue + } + + seqsToDelete.append( + contentsOf: group.dropFirst().compactMap { + $0.sql.uppercased().hasPrefix("UPDATE") ? $0.seq : nil + } + ) + } + + guard !seqsToDelete.isEmpty else { return } + let sqlList = seqsToDelete.map(String.init).joined(separator: ",") + try db.execute(sql: "DELETE FROM \"sqlitedata_undo_log\" WHERE \"seq\" IN (\(sqlList))") +} + +// MARK: - Helpers + +private func undoDoubleQuotedIdentifier(_ identifier: String) -> String { + "\"" + identifier.replacingOccurrences(of: "\"", with: "\"\"") + "\"" +} diff --git a/Sources/SQLiteData/Undo/SQLiteUndoManager.swift b/Sources/SQLiteData/Undo/SQLiteUndoManager.swift new file mode 100644 index 00000000..87e4a49e --- /dev/null +++ b/Sources/SQLiteData/Undo/SQLiteUndoManager.swift @@ -0,0 +1,2 @@ +/// Preferred name for SQLiteData's undo manager to avoid clashing with Foundation's type. +public typealias SQLiteUndoManager = UndoManager diff --git a/Sources/SQLiteData/Undo/UndoAction.swift b/Sources/SQLiteData/Undo/UndoAction.swift new file mode 100644 index 00000000..e383db98 --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoAction.swift @@ -0,0 +1,7 @@ +/// Whether an undo manager operation is an undo or a redo. +public enum UndoAction: Sendable { + /// An undo operation that reverts the most-recently-recorded change. + case undo + /// A redo operation that re-applies the most-recently-undone change. + case redo +} diff --git a/Sources/SQLiteData/Undo/UndoEvent.swift b/Sources/SQLiteData/Undo/UndoEvent.swift new file mode 100644 index 00000000..a20a8fce --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoEvent.swift @@ -0,0 +1,47 @@ +import Foundation +import StructuredQueriesCore + +public struct UndoAffectedRow: Sendable, Hashable { + public let tableName: String + public let rowID: Int + + package 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 + } + + public func id( + as type: T.Type + ) -> T.ID? where T.ID: BinaryInteger { + tableName == T.tableName ? T.ID(rowID) : nil + } +} + +public struct UndoEvent: Sendable, Equatable { + public enum Kind: Sendable, Equatable { + case undo + case redo + } + + public let kind: Kind + public let group: UndoGroup + public let affectedRows: Set + + public init(kind: Kind, group: UndoGroup, affectedRows: Set) { + self.kind = kind + self.group = group + self.affectedRows = affectedRows + } + + public func ids( + for type: T.Type + ) -> Set? where T.ID: BinaryInteger { + let ids = Set(affectedRows.compactMap { $0.id(as: type) }) + return ids.isEmpty ? nil : ids + } +} diff --git a/Sources/SQLiteData/Undo/UndoGroup.swift b/Sources/SQLiteData/Undo/UndoGroup.swift new file mode 100644 index 00000000..b935cfda --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoGroup.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A named group of database changes that can be undone or redone as a single unit. +public struct UndoGroup: Sendable, Identifiable, Equatable { + /// A unique identifier for this group. + public let id: UUID + /// A human-readable description of the change, e.g. "Add reminder". + public let description: String + /// An identifier for the device that originated the change. + public let deviceID: String + /// The iCloud record name of the user who made the change, or `nil` if this is the current user + /// or sync is not configured. + public let userRecordName: String? + /// The date the change was recorded. + public let date: Date + + package init( + id: UUID = UUID(), + description: String, + deviceID: String, + userRecordName: String?, + date: Date + ) { + self.id = id + self.description = description + self.deviceID = deviceID + self.userRecordName = userRecordName + self.date = date + } +} diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift new file mode 100644 index 00000000..73b255bc --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -0,0 +1,666 @@ +import ConcurrencyExtras +import Foundation +import GRDB +import IssueReporting +import Perception +#if canImport(Observation) + import Observation +#endif +import StructuredQueriesCore + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// Tracks changes made to a SQLite database and lets you undo and redo them. +/// +/// Prefer ``SQLiteUndoManager`` in your code when you also work with `Foundation.UndoManager`. +/// +/// Create an `UndoManager` after the database is open, supplying the table names whose changes +/// you want to track. The manager installs lightweight SQLite triggers that record inverse SQL +/// statements into a temporary log table. +/// +/// ```swift +/// let undoManager = try UndoManager( +/// for: database, +/// tables: Reminder.self, ReminderTag.self, +/// deviceID: UIDevice.current.identifierForVendor?.uuidString ?? "" +/// ) +/// +/// // Record a named group of changes +/// try await undoManager.withGroup("Add reminder") { db in +/// try Reminder.insert { Reminder.Draft(title: "Buy milk") }.execute(db) +/// } +/// +/// // Undo the most-recent group +/// try await undoManager.undo() +/// ``` +/// +/// ## CloudKit sync compatibility +/// +/// Changes written by a `SyncEngine` can be recorded as undo groups, including synced-origin +/// metadata. +public final class UndoManager: Perceptible, @unchecked Sendable { + private final class WeakUndoManager: @unchecked Sendable { + weak var value: UndoManager? + init(_ value: UndoManager) { + self.value = value + } + } + + private static let _managersByID = LockIsolated([ObjectIdentifier: WeakUndoManager]()) + package static let syncDeviceID = "sqlitedata-sync" + + // MARK: - Internal state + + private struct State { + var undoEntries: [UndoEntry] = [] + var redoEntries: [UndoEntry] = [] + var activeBarrier: (id: UUID, barrier: OpenBarrier)? + /// The next `seq` value that will begin a new undo group. + var firstLog: Int = 1 + /// The first log sequence captured by the outermost freeze. + var freezePoint: Int = -1 + /// Nesting count for `freeze()`/`unfreeze()`. + var freezeDepth: Int = 0 + } + + private struct OpenBarrier: Sendable { + var group: UndoGroup + var firstLog: Int + } + + public enum BarrierError: Error { + case alreadyOpen + case notFound + } + + private let _state = LockIsolated(State()) + private let database: any DatabaseWriter + private let databaseID: ObjectIdentifier + private let deviceID: String + private let userRecordName: @Sendable () -> String? + private let trackedTableNames: Set + private let delegate: (any UndoManagerDelegate)? + private let eventsContinuation: AsyncStream.Continuation + public let events: AsyncStream + #if canImport(ObjectiveC) + private weak var foundationUndoManager: Foundation.UndoManager? + #endif + + // MARK: - Observable conformance (Perception) + + private let _$perceptionRegistrar = PerceptionRegistrar() + + nonisolated public func access( + keyPath: KeyPath + ) { + _$perceptionRegistrar.access(self, keyPath: keyPath) + } + + nonisolated public func withMutation( + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + try _$perceptionRegistrar.withMutation(of: self, keyPath: keyPath, mutation) + } + + // MARK: - Observable state + + /// The groups that can be undone, most-recent-first. + public var undoStack: [UndoGroup] { + _$perceptionRegistrar.access(self, keyPath: \.undoStack) + return _state.value.undoEntries.reversed().map(\.group) + } + + /// The groups that can be redone, most-recent-first. + public var redoStack: [UndoGroup] { + _$perceptionRegistrar.access(self, keyPath: \.redoStack) + return _state.value.redoEntries.reversed().map(\.group) + } + + /// Whether there is at least one group that can be undone. + public var canUndo: Bool { !undoStack.isEmpty } + + /// Whether there is at least one group that can be redone. + public var canRedo: Bool { !redoStack.isEmpty } + + // MARK: - Init + + /// Creates an undo manager and installs undo triggers on the database. + /// + /// The triggers and the temporary log table are created immediately on the writer connection. + /// + /// - Parameters: + /// - database: The database to observe. + /// - tables: The names of the tables whose changes should be undoable. + /// - deviceID: An identifier for this device shown in ``UndoGroup/deviceID``. + /// Defaults to the system device identifier. + /// - userRecordName: A closure returning the current user's iCloud record name, or `nil`. + /// - delegate: An optional delegate that can intercept and confirm undo/redo operations. + public init< + each T: PrimaryKeyedTable & _SendableMetatype + >( + for database: any DatabaseWriter, + tables: repeat (each T).Type, + deviceID: String = UndoManager.defaultDeviceID, + userRecordName: @Sendable @escaping () -> String? = { nil }, + delegate: (any UndoManagerDelegate)? = nil + ) throws { + var trackedTableNames = Set() + for table in repeat each tables { + trackedTableNames.insert(table.tableName) + } + (self.events, self.eventsContinuation) = AsyncStream.makeStream() + self.database = database + self.databaseID = ObjectIdentifier(database as AnyObject) + self.deviceID = deviceID + self.userRecordName = userRecordName + self.delegate = delegate + self.trackedTableNames = trackedTableNames + + // One-time setup on the writer connection: register the custom function, + // create the temp log table, and install triggers for each observed table. + try database.write { db in + db.add(function: $_shouldRecord) + db.add(function: $_isReplaying) + + try db.execute(sql: undoLogTableSQL) + + for table in repeat each tables { + let tableName = table.tableName + let columns = try undoColumnNames(for: tableName, in: db) + guard !columns.isEmpty else { continue } + for sql in undoTriggerSQL(for: tableName, columns: columns) { + try db.execute(sql: sql) + } + } + } + + Self._managersByID.withValue { + $0[self.databaseID] = WeakUndoManager(self) + } + } + + deinit { + Self._managersByID.withValue { + if $0[self.databaseID]?.value === self { + $0.removeValue(forKey: self.databaseID) + } + } + } + + package static func manager(for database: any DatabaseWriter) -> UndoManager? { + _managersByID.withValue { + $0 = $0.filter { $0.value.value != nil } + return $0[ObjectIdentifier(database as AnyObject)]?.value + } + } + + package func manages(database: any DatabaseWriter) -> Bool { + databaseID == ObjectIdentifier(database as AnyObject) + } + + #if canImport(ObjectiveC) + /// Binds this manager to Foundation's undo manager for seamless system undo/redo integration. + /// + /// When bound, SQLiteData undo/redo operations are registered with the Foundation manager so + /// keyboard shortcuts and responder-chain undo work with the same stack. + public func bind(to foundationUndoManager: Foundation.UndoManager?) { + self.foundationUndoManager = foundationUndoManager + } + #endif + + // MARK: - Static helpers + + /// A device identifier suitable for use with ``init(for:tables:deviceID:userRecordName:delegate:)``. + /// + /// On iOS this is `UIDevice.identifierForVendor`; on macOS it is the machine's host name. + public static var defaultDeviceID: String { + #if canImport(UIKit) + return UIDevice.current.identifierForVendor?.uuidString ?? ProcessInfo.processInfo.hostName + #else + return ProcessInfo.processInfo.hostName + #endif + } + + /// A SQL expression that reports whether undo/redo replay is currently executing. + /// + /// Use this in application trigger `WHEN` clauses to suppress side-effect writes during replay. + public static func isReplaying() -> some QueryExpression { + $_isReplaying() + } + + // MARK: - Group recording + + /// Begins recording a barrier that can later be ended or cancelled. + /// + /// Use this API when an undoable action spans multiple writes or async boundaries. + @discardableResult + public func beginBarrier( + _ description: String, + deviceID: String? = nil, + userRecordName: String? = nil + ) throws -> UUID { + let group = UndoGroup( + description: description, + deviceID: deviceID ?? self.deviceID, + userRecordName: userRecordName ?? self.userRecordName(), + date: Date() + ) + let barrierID = UUID() + try _state.withValue { state in + guard state.activeBarrier == nil else { throw BarrierError.alreadyOpen } + state.activeBarrier = ( + id: barrierID, + barrier: OpenBarrier(group: group, firstLog: state.firstLog) + ) + } + return barrierID + } + + /// Ends a previously opened barrier and pushes it to undo history if changes were recorded. + @discardableResult + public func endBarrier(_ barrierID: UUID) throws -> UndoGroup? { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + let summary = try database.write { db -> (maxSeq: Int, modifiedTables: Set)? in + guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + maxSeq >= barrier.firstLog + else { + return nil + } + try undoReconcileEntries(in: db, from: barrier.firstLog, to: maxSeq) + maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + guard maxSeq >= barrier.firstLog else { return nil } + return (maxSeq, try undoModifiedTableNames(in: db, from: barrier.firstLog, to: maxSeq)) + } + guard let summary else { return nil } + return finalizeBarrier( + barrier, + maxSeq: summary.maxSeq, + modifiedTables: summary.modifiedTables + ) + } + + /// Async variant of ``endBarrier(_:)``. + @discardableResult + public func endBarrier(_ barrierID: UUID) async throws -> UndoGroup? { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + let summary = try await database.write { db -> (maxSeq: Int, modifiedTables: Set)? in + guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + maxSeq >= barrier.firstLog + else { + return nil + } + try undoReconcileEntries(in: db, from: barrier.firstLog, to: maxSeq) + maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + guard maxSeq >= barrier.firstLog else { return nil } + return (maxSeq, try undoModifiedTableNames(in: db, from: barrier.firstLog, to: maxSeq)) + } + guard let summary else { return nil } + return finalizeBarrier( + barrier, + maxSeq: summary.maxSeq, + modifiedTables: summary.modifiedTables + ) + } + + /// Cancels a previously opened barrier and discards any undo log entries captured for it. + public func cancelBarrier(_ barrierID: UUID) throws { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + try database.write { db in + try UndoLog + .where { $0.seq >= barrier.firstLog } + .delete() + .execute(db) + } + _state.withValue { state in + state.firstLog = barrier.firstLog + } + } + + /// Async variant of ``cancelBarrier(_:)``. + public func cancelBarrier(_ barrierID: UUID) async throws { + let barrier = try _state.withValue { state -> OpenBarrier in + guard let activeBarrier = state.activeBarrier, activeBarrier.id == barrierID else { + throw BarrierError.notFound + } + state.activeBarrier = nil + return activeBarrier.barrier + } + try await database.write { db in + try UndoLog + .where { $0.seq >= barrier.firstLog } + .delete() + .execute(db) + } + _state.withValue { state in + state.firstLog = barrier.firstLog + } + } + + /// Performs `body` inside a database write transaction and records all changes as a named + /// undo group. + /// + /// If `body` makes no changes (or triggers are suppressed because recording is frozen), no + /// undo entry is added. + /// + /// Calling this method clears the redo stack. + /// + /// - Parameters: + /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. + /// - body: A closure that performs database writes. Receives a `Database` connection. + /// - Returns: The value returned by `body`. + @discardableResult + public func withGroup( + _ description: String, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: @Sendable (Database) throws -> T + ) async throws -> T { + let barrierID = try beginBarrier( + description, + deviceID: deviceID, + userRecordName: userRecordName + ) + do { + let result = try await database.write { db in + try body(db) + } + _ = try await endBarrier(barrierID) + return result + } catch { + try await cancelBarrier(barrierID) + throw error + } + } + + /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + @discardableResult + public func withGroup( + _ description: String, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: (Database) throws -> T + ) throws -> T { + let barrierID = try beginBarrier( + description, + deviceID: deviceID, + userRecordName: userRecordName + ) + do { + let result = try database.write { db in + try body(db) + } + _ = try endBarrier(barrierID) + return result + } catch { + try cancelBarrier(barrierID) + throw error + } + } + + // MARK: - Undo / Redo + + /// Reverts the most-recently-recorded undo group. + /// + /// The delegate (if any) is called before the operation is performed so that you can present a + /// confirmation prompt. + public func undo() async throws { + try await perform(.undo) + } + + /// Re-applies the most-recently-undone group. + /// + /// The delegate (if any) is called before the operation is performed so that you can present a + /// confirmation prompt. + public func redo() async throws { + try await perform(.redo) + } + + // MARK: - Freeze / Unfreeze + + /// Suspends undo recording. + /// + /// Changes made while recording is frozen are not added to the undo stack. Call ``unfreeze()`` + /// to resume recording. Calls to ``freeze()`` and ``unfreeze()`` may be nested. + public func freeze() async throws { + try await database.write { _ in + self._state.withValue { state in + if state.freezeDepth == 0 { + state.freezePoint = state.firstLog + } + state.freezeDepth += 1 + } + } + } + + /// Resumes undo recording after a call to ``freeze()``. + /// + /// Any log entries written while frozen are discarded, and ``firstLog`` is advanced past them. + public func unfreeze() async throws { + let shouldFinalizeFreeze = _state.withValue { state in + guard state.freezeDepth > 0 else { return false } + state.freezeDepth -= 1 + return state.freezeDepth == 0 + } + guard shouldFinalizeFreeze else { return } + + let maxSeq = try await database.write { db in + try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + } + _state.withValue { state in + guard state.freezeDepth == 0, state.freezePoint >= 0 else { return } + state.firstLog = maxSeq + 1 + state.freezePoint = -1 + } + } + + // MARK: - Private helpers + + private func finalizeBarrier( + _ barrier: OpenBarrier, + maxSeq: Int, + modifiedTables: Set + ) -> UndoGroup? { + guard maxSeq >= barrier.firstLog else { + return nil + } + let unknownTables = modifiedTables.subtracting(trackedTableNames) + if !unknownTables.isEmpty { + reportIssue( + """ + Undo group '\(barrier.group.description)' recorded changes for unexpected tables: \ + \(unknownTables.sorted().joined(separator: ", ")). + """ + ) + } + let entry = UndoEntry(begin: barrier.firstLog, end: maxSeq, group: barrier.group) + let shouldRecord = _state.withValue { $0.freezePoint < 0 } + _$perceptionRegistrar.withMutation(of: self, keyPath: \.undoStack) { + _$perceptionRegistrar.withMutation(of: self, keyPath: \.redoStack) { + _state.withValue { state in + if shouldRecord { + state.undoEntries.append(entry) + state.redoEntries = [] + state.firstLog = maxSeq + 1 + } + } + } + } + if shouldRecord { + registerFoundationAction(.undo, group: barrier.group) + return barrier.group + } + return nil + } + + private func perform(_ action: UndoAction) async throws { + // Peek at the entry to pass to the delegate. + let entry: UndoEntry? = _state.withValue { state in + switch action { + case .undo: return state.undoEntries.last + case .redo: return state.redoEntries.last + } + } + guard let entry else { return } + + let performAction: @Sendable () async throws -> Void = { [weak self] in + guard let self else { return } + try await self.applyInverse(of: entry, action: action) + } + + if let delegate { + try await delegate.undoManager(self, willPerform: action, for: entry.group, performAction: performAction) + } else { + try await performAction() + } + } + + private func applyInverse(of entry: UndoEntry, action: UndoAction) async throws { + let firstLog = _state.value.firstLog + + // Execute inverse SQL inside a write transaction. + // Triggers must run so that inverse-of-inverse statements are recorded for the opposite stack. + let affectedRows = try await $_isUndoingOrRedoing.withValue(true) { + try await database.write { db in + // Fetch inverse SQL rows in reverse order (highest seq first = undo in LIFO order). + let rows = try UndoLog + .where { $0.seq >= entry.begin && $0.seq <= entry.end } + .order { $0.seq.desc() } + .fetchAll(db) + + let affectedRows = Set( + rows + .filter { $0.trackedRowID != 0 } + .map { UndoAffectedRow(tableName: $0.tableName, rowID: $0.trackedRowID) } + ) + + // Remove these rows from the log before executing so re-entrant calls don't see them. + try UndoLog + .where { $0.seq >= entry.begin && $0.seq <= entry.end } + .delete() + .execute(db) + + // Replayed statements can include child-before-parent row restoration from cascading + // deletes. Deferring FK checks until commit lets the full inverse set restore first. + try db.execute(sql: "PRAGMA defer_foreign_keys = ON") + + // Execute each inverse SQL statement in order. + for row in rows { + try db.execute(sql: row.sql) + } + return affectedRows + } + } + + // The triggers fired during `applyInverse` will have added new rows to the log. + let newEnd = try await database.write { db -> Int in + guard var newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq else { return 0 } + if newEnd >= firstLog { + try undoReconcileEntries(in: db, from: firstLog, to: newEnd) + newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + } + return newEnd + } + let didAppend = newEnd >= firstLog + + let newEntry = UndoEntry(begin: firstLog, end: newEnd, group: entry.group) + + _$perceptionRegistrar.withMutation(of: self, keyPath: \.undoStack) { + _$perceptionRegistrar.withMutation(of: self, keyPath: \.redoStack) { + _state.withValue { state in + switch action { + case .undo: + state.undoEntries.removeLast() + if didAppend { + state.redoEntries.append(newEntry) + } + case .redo: + state.redoEntries.removeLast() + if didAppend { + state.undoEntries.append(newEntry) + } + } + state.firstLog = newEnd + 1 + } + } + } + + guard didAppend else { return } + let eventKind: UndoEvent.Kind + switch action { + case .undo: eventKind = .undo + case .redo: eventKind = .redo + } + eventsContinuation.yield( + UndoEvent( + kind: eventKind, + group: entry.group, + affectedRows: affectedRows + ) + ) + } + + #if canImport(ObjectiveC) + private func registerFoundationAction(_ action: UndoAction, group: UndoGroup) { + Task { @MainActor [weak self] in + guard let self else { return } + self.registerFoundationActionOnMain(action, group: group) + } + } + + @MainActor + private func registerFoundationActionOnMain(_ action: UndoAction, group: UndoGroup) { + guard let foundationUndoManager else { return } + foundationUndoManager.registerUndo(withTarget: self) { target in + let inverseAction: UndoAction + switch action { + case .undo: inverseAction = .redo + case .redo: inverseAction = .undo + } + target.registerFoundationActionOnMain(inverseAction, group: group) + Task { + do { + switch action { + case .undo: + try await target.undo() + case .redo: + try await target.redo() + } + } catch { + assertionFailure("SQLiteUndoManager failed to perform Foundation undo action: \(error)") + } + } + } + foundationUndoManager.setActionName(group.description) + } + #else + private func registerFoundationAction(_ action: UndoAction, group: UndoGroup) {} + #endif +} + +#if canImport(Observation) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension UndoManager: Observable {} +#endif diff --git a/Sources/SQLiteData/Undo/UndoManagerDelegate.swift b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift new file mode 100644 index 00000000..5f2eaa3e --- /dev/null +++ b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift @@ -0,0 +1,34 @@ +/// A delegate that an ``UndoManager`` calls before performing an undo or redo operation. +/// +/// The delegate can present a confirmation prompt, or perform any async work, before calling +/// `performAction` to commit the operation. If `performAction` is not called, the operation is +/// cancelled and the undo/redo stacks remain unchanged. +/// +/// The default implementation performs the action immediately without any confirmation. +public protocol UndoManagerDelegate: AnyObject, Sendable { + /// Called before the undo manager performs an undo or redo operation. + /// + /// - Parameters: + /// - undoManager: The undo manager requesting the action. + /// - action: Whether this is an undo or redo. + /// - group: The group of changes that will be undone or redone. + /// - performAction: Call this to commit the operation. Omitting this call cancels it. + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws +} + +extension UndoManagerDelegate { + /// Default implementation: immediately performs the action without confirmation. + public func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + try await performAction() + } +} diff --git a/Sources/SQLiteData/Undo/WithoutUndo.swift b/Sources/SQLiteData/Undo/WithoutUndo.swift new file mode 100644 index 00000000..55cd718c --- /dev/null +++ b/Sources/SQLiteData/Undo/WithoutUndo.swift @@ -0,0 +1,23 @@ +/// Executes work while undo trigger recording is disabled. +/// +/// Use this to perform writes that should not become undoable entries. +@discardableResult +public func withoutUndo( + _ operation: () throws -> T +) rethrows -> T { + try $_isUndoRecordingDisabled.withValue(true) { + try operation() + } +} + +/// Executes async work while undo trigger recording is disabled. +/// +/// Use this to perform writes that should not become undoable entries. +@discardableResult +public func withoutUndo( + _ operation: @Sendable () async throws -> T +) async rethrows -> T { + try await $_isUndoRecordingDisabled.withValue(true) { + try await operation() + } +} diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift new file mode 100644 index 00000000..ff593ebe --- /dev/null +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -0,0 +1,754 @@ +import Foundation +import Dependencies +import SQLiteData +import Testing +#if canImport(CloudKit) + import CloudKit +#endif + +// MARK: - Schema + +@Table private struct Item: Equatable, Identifiable { + let id: Int + var title: String +} + +@Table("notes") private struct Note: Equatable, Identifiable { + let id: Int + var body: String? +} + +@Table("audits") private struct Audit: Equatable, Identifiable { + let id: Int + var message: String +} + +@Table("parents") private struct Parent: Equatable, Identifiable { + let id: Int + var name: String +} + +@Table("children") private struct Child: Equatable, Identifiable { + let id: Int + var parentID: Int + var name: String +} + +// MARK: - Database helpers + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func undoDatabase() throws -> DatabaseQueue { + let database = try DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create items") { db in + try #sql( + """ + CREATE TABLE "items" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL DEFAULT '' + ) + """ + ) + .execute(db) + } + try migrator.migrate(database) + return database + } +} + +// MARK: - Tests + +@Suite struct UndoManagerTests { + @Test func defaultUndoManagerDependencyDefaultsToNil() { + @Dependency(\.defaultUndoManager) var defaultUndoManager + #expect(defaultUndoManager == nil) + } + + + // 1. Basic undo removes the inserted row and leaves canUndo false. + @Test func basicUndo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + #expect(undoManager.canUndo) + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + #expect(!undoManager.canUndo) + #expect(undoManager.undoStack.isEmpty) + } + + // 2. After undo, redo restores the row and leaves canRedo false. + @Test func basicRedo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + try await undoManager.undo() + #expect(undoManager.canRedo) + + try await undoManager.redo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + #expect(items[0].title == "Hello") + #expect(!undoManager.canRedo) + #expect(undoManager.redoStack.isEmpty) + } + + // 3. Two inserts in one withGroup are undone together. + @Test func undoGroup() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Batch insert") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + // 4. Separate groups produce separate undo entries; undoing removes only the last one. + @Test func multipleGroups() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await undoManager.withGroup("Insert B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + #expect(undoManager.undoStack.count == 2) + + try await undoManager.undo() + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + #expect(items[0].title == "A") + #expect(undoManager.undoStack.count == 1) + } + + // 5. Sync-origin writes can be grouped, undone, and carry synced-origin metadata. + @Test func syncIncludedWithMetadata() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await $_isSynchronizingChanges.withValue(true) { + try await undoManager.withGroup( + "Sync insert", + deviceID: UndoManager.syncDeviceID, + userRecordName: "collaborator-user" + ) { db in + _ = try Item.insert { Item.Draft(title: "Sync item") }.execute(db) + } + } + + #expect(undoManager.canUndo) + #expect(undoManager.undoStack.first?.deviceID == UndoManager.syncDeviceID) + #expect(undoManager.undoStack.first?.userRecordName == "collaborator-user") + + try await undoManager.undo() + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + // 6. Inverse SQL executed during undo is not added to the undo stack; it goes to redo. + @Test func undoingNotRecorded() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "X") }.execute(db) + } + try await undoManager.undo() + + // Only the redo entry should exist; no additional undo entry. + #expect(undoManager.undoStack.isEmpty) + #expect(undoManager.redoStack.count == 1) + } + + // 7. Changes made while frozen are not undoable. + @Test func freeze() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.freeze() + // Direct write (not through withGroup) so we can test the trigger suppression via freeze. + try await db.write { db in + _ = try Item.insert { Item.Draft(title: "Frozen") }.execute(db) + } + try await undoManager.unfreeze() + + #expect(!undoManager.canUndo) + #expect(undoManager.undoStack.isEmpty) + + // The row should still be in the database. + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + // 8. When the delegate does not call performAction, the undo is cancelled. + @Test func explicitBarrierLifecycle() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + let barrierID = try undoManager.beginBarrier("Insert via barrier") + try await db.write { db in + _ = try Item.insert { Item.Draft(title: "Barrier item") }.execute(db) + } + let group = try await undoManager.endBarrier(barrierID) + + #expect(group?.description == "Insert via barrier") + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + @Test func cancelBarrierDropsUndoRegistration() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + let barrierID = try undoManager.beginBarrier("Cancelled barrier") + try await db.write { db in + _ = try Item.insert { Item.Draft(title: "Not undoable") }.execute(db) + } + try await undoManager.cancelBarrier(barrierID) + + #expect(undoManager.undoStack.isEmpty) + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + @Test func withoutUndoSuppressesRecording() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await withoutUndo { + try await undoManager.withGroup("Suppressed insert") { db in + _ = try Item.insert { Item.Draft(title: "Suppressed") }.execute(db) + } + } + + #expect(undoManager.undoStack.isEmpty) + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + @Test func replayFunctionSuppressesAppTriggersDuringUndo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await db.write { db in + try db.execute( + sql: """ + CREATE TABLE "audit_log" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "message" TEXT NOT NULL + ) + """ + ) + try db.execute( + sql: """ + CREATE TEMP TRIGGER "item_delete_audit" + AFTER DELETE ON "items" + WHEN NOT "sqlitedata_undo_isReplaying"() + BEGIN + INSERT INTO "audit_log" ("message") VALUES ('delete ' || OLD."title"); + END + """ + ) + } + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Guarded") }.execute(db) + } + try await undoManager.undo() + + let auditCount = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "audit_log""#) ?? 0 + } + #expect(auditCount == 0) + } + + @Test func undoEventEmittedAfterUndo() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + var iterator = undoManager.events.makeAsyncIterator() + + try await undoManager.withGroup("Insert event") { db in + _ = try Item.insert { Item.Draft(title: "Event item") }.execute(db) + } + try await undoManager.undo() + + let event = await iterator.next() + #expect(event?.kind == .undo) + #expect(event?.group.description == "Insert event") + #expect(event?.ids(for: Item.self) == [1]) + } + + @Test func noOpGroupIsReconciledAway() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert then delete") { db in + try db.execute(sql: #"INSERT INTO "items" ("title") VALUES ('Temp')"#) + let id = db.lastInsertedRowID + try db.execute(sql: #"DELETE FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + + #expect(undoManager.undoStack.isEmpty) + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(count == 0) + } + + @Test func undoDeleteWithCascadeRestoresParentAndChild() async throws { + let db = try DatabaseQueue() + try await db.write { db in + try db.execute( + sql: """ + CREATE TABLE "parents" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL DEFAULT '' + ) + """ + ) + try db.execute( + sql: """ + CREATE TABLE "children" ( + "id" INTEGER PRIMARY KEY, + "parentID" INTEGER NOT NULL REFERENCES "parents"("id") ON DELETE CASCADE, + "name" TEXT NOT NULL DEFAULT '' + ) + """ + ) + } + let undoManager = try UndoManager(for: db, tables: Parent.self, Child.self) + + try await withoutUndo { + try await db.write { db in + try db.execute(sql: #"INSERT INTO "parents" ("id","name") VALUES (1,'P')"#) + try db.execute(sql: #"INSERT INTO "children" ("id","parentID","name") VALUES (1,1,'C')"#) + } + } + + try await undoManager.withGroup("Delete parent") { db in + try db.execute(sql: #"DELETE FROM "parents" WHERE "id" = 1"#) + } + + let deletedCounts = try await db.read { db in + ( + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "parents""#) ?? 0, + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "children""#) ?? 0 + ) + } + #expect(deletedCounts.0 == 0) + #expect(deletedCounts.1 == 0) + + try await undoManager.undo() + + let restoredCounts = try await db.read { db in + ( + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "parents""#) ?? 0, + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "children""#) ?? 0 + ) + } + #expect(restoredCounts.0 == 1) + #expect(restoredCounts.1 == 1) + } + + @Test func warnsOnUnexpectedTrackedTableNames() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await db.write { db in + try db.execute( + sql: """ + CREATE TABLE "audits" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "message" TEXT NOT NULL DEFAULT '' + ) + """ + ) + let columns = try undoColumnNames(for: "audits", in: db) + for sql in undoTriggerSQL(for: "audits", columns: columns) { + try db.execute(sql: sql) + } + } + + try await withKnownIssue { + try await undoManager.withGroup("Mixed tracked tables") { db in + _ = try Item.insert { Item.Draft(title: "Item") }.execute(db) + _ = try Audit.insert { Audit.Draft(message: "Audit") }.execute(db) + } + } matching: { issue in + issue.description.contains("unexpected tables: audits") + } + } + + @Test func delegateCancel() async throws { + final class CancelDelegate: UndoManagerDelegate { + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + // Intentionally do NOT call performAction — cancel the undo. + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = CancelDelegate() + let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Persistent") }.execute(db) + } + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + // Stack unchanged; row still present. + #expect(undoManager.undoStack.count == 1) + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + // 9. The delegate receives metadata matching what was passed to withGroup. + @Test func delegateReceivesMetadata() async throws { + actor MetadataCapture { + var capturedGroup: UndoGroup? + func capture(_ group: UndoGroup) { capturedGroup = group } + } + let capture = MetadataCapture() + + final class MetadataDelegate: UndoManagerDelegate, @unchecked Sendable { + let capture: MetadataCapture + init(_ capture: MetadataCapture) { self.capture = capture } + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + await capture.capture(group) + try await performAction() + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = MetadataDelegate(capture) + let undoManager = try UndoManager( + for: db, + tables: Item.self, + deviceID: "test-device", + delegate: delegate + ) + + try await undoManager.withGroup("My operation") { db in + _ = try Item.insert { Item.Draft(title: "Hi") }.execute(db) + } + try await undoManager.undo() + + let group = await capture.capturedGroup + #expect(group?.description == "My operation") + #expect(group?.deviceID == "test-device") + } + + // 10. The delegate receives `.undo` for undo and `.redo` for redo. + @Test func delegateActionType() async throws { + actor ActionCapture { + var actions: [UndoAction] = [] + func append(_ action: UndoAction) { actions.append(action) } + } + let capture = ActionCapture() + + final class ActionDelegate: UndoManagerDelegate, @unchecked Sendable { + let capture: ActionCapture + init(_ capture: ActionCapture) { self.capture = capture } + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + await capture.append(action) + try await performAction() + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = ActionDelegate(capture) + let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Z") }.execute(db) + } + try await undoManager.undo() + try await undoManager.redo() + + let actions = await capture.actions + #expect(actions == [.undo, .redo]) + } + + // 11. The description from withGroup appears in undoStack. + @Test func undoDescriptionRoundtrip() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Delete all items") { db in + _ = try Item.insert { Item.Draft(title: "Temp") }.execute(db) + } + + #expect(undoManager.undoStack.first?.description == "Delete all items") + } + + // 12. Nested freeze calls require matching unfreeze calls before recording resumes. + @Test func nestedFreezeRequiresMatchingUnfreeze() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.freeze() + try await undoManager.freeze() + + try await undoManager.withGroup("Frozen A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await undoManager.unfreeze() + + try await undoManager.withGroup("Frozen B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + + #expect(!undoManager.canUndo) + + try await undoManager.unfreeze() + + try await undoManager.withGroup("Tracked C") { db in + _ = try Item.insert { Item.Draft(title: "C") }.execute(db) + } + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + let titles = try await db.read { db in + try String.fetchAll(db, sql: "SELECT title FROM items ORDER BY id") + } + #expect(titles == ["A", "B"]) + } + + // 13. Undo/redo round-trips updates containing SQL-sensitive quoting characters. + @Test func updateUndoRedoQuotedText() async throws { + let db = try DatabaseQueue.undoDatabase() + let id = try await db.write { db in + try db.execute(sql: #"INSERT INTO "items" ("title") VALUES (?)"#, arguments: ["Before"]) + return db.lastInsertedRowID + } + let undoManager = try UndoManager(for: db, tables: Item.self) + let updatedTitle = #"O'Reilly "Book""# + + try await undoManager.withGroup("Quoted update") { db in + try db.execute( + sql: #"UPDATE "items" SET "title" = ? WHERE "id" = ?"#, + arguments: [updatedTitle, id] + ) + } + + let titleAfterUpdate = try await db.read { db in + try String.fetchOne(db, sql: #"SELECT "title" FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + #expect(titleAfterUpdate == updatedTitle) + + try await undoManager.undo() + let titleAfterUndo = try await db.read { db in + try String.fetchOne(db, sql: #"SELECT "title" FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + #expect(titleAfterUndo == "Before") + + try await undoManager.redo() + let titleAfterRedo = try await db.read { db in + try String.fetchOne(db, sql: #"SELECT "title" FROM "items" WHERE "id" = ?"#, arguments: [id]) + } + #expect(titleAfterRedo == updatedTitle) + } + + // 14. Deleting rows with NULL values can be undone/redone correctly. + @Test func deleteUndoRedoNullColumn() async throws { + let db = try DatabaseQueue() + try await db.write { db in + try db.execute(sql: #"CREATE TABLE "notes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "body" TEXT)"#) + } + let id = try await db.write { db in + try db.execute(sql: #"INSERT INTO "notes" ("body") VALUES (NULL)"#) + return db.lastInsertedRowID + } + let undoManager = try UndoManager(for: db, tables: Note.self) + + try await undoManager.withGroup("Delete null row") { db in + try db.execute(sql: #"DELETE FROM "notes" WHERE "id" = ?"#, arguments: [id]) + } + + let countAfterDelete = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "notes" WHERE "id" = ?"#, arguments: [id]) ?? 0 + } + #expect(countAfterDelete == 0) + + try await undoManager.undo() + let countAfterUndo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "notes" WHERE "id" = ?"#, arguments: [id]) ?? 0 + } + let restoredIsNull = try await db.read { db in + try Int.fetchOne( + db, + sql: #"SELECT "body" IS NULL FROM "notes" WHERE "id" = ?"#, + arguments: [id] + ) ?? 0 + } + #expect(countAfterUndo == 1) + #expect(restoredIsNull == 1) + + try await undoManager.redo() + let countAfterRedo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "notes" WHERE "id" = ?"#, arguments: [id]) ?? 0 + } + #expect(countAfterRedo == 0) + } + + #if canImport(ObjectiveC) + @Test func foundationUndoBridgeRoundTrip() async throws { + let db = try DatabaseQueue.undoDatabase() + let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) + let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } + sqliteUndoManager.bind(to: foundationUndoManager) + + try await sqliteUndoManager.withGroup("Insert via bridge") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + + try await waitUntil { + await MainActor.run { foundationUndoManager.canUndo } + } + #expect(await MainActor.run { foundationUndoManager.canUndo }) + + await MainActor.run { foundationUndoManager.undo() } + + try await waitUntil { + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + return count == 0 && sqliteUndoManager.canRedo + } + + let countAfterUndo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(countAfterUndo == 0) + #expect(sqliteUndoManager.canRedo) + #expect(await MainActor.run { foundationUndoManager.canRedo }) + + await MainActor.run { foundationUndoManager.redo() } + + try await waitUntil { + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + return count == 1 && sqliteUndoManager.canUndo + } + + let countAfterRedo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(countAfterRedo == 1) + #expect(sqliteUndoManager.canUndo) + } + + @Test func foundationUndoBridgeUnboundFallback() async throws { + let db = try DatabaseQueue.undoDatabase() + let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) + let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } + + try await sqliteUndoManager.withGroup("Standalone insert") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(sqliteUndoManager.canUndo) + #expect(!(await MainActor.run { foundationUndoManager.canUndo })) + } + + private func waitUntil( + _ condition: @escaping @Sendable () async throws -> Bool + ) async throws { + for _ in 0..<200 { + if try await condition() { + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(Bool(false)) + } + #endif + + #if canImport(CloudKit) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncEngineWriteWrappedByUserDatabaseIsUndoable() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) + } + } + + #expect(undoManager.undoStack.count == 1) + #expect(undoManager.undoStack.first?.deviceID == UndoManager.syncDeviceID) + #expect(undoManager.undoStack.first?.userRecordName == zoneID.ownerName) + + try await undoManager.undo() + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncEngineWriteWithoutUndoManagerStillWorks() async throws { + try await withDependencies { + $0.defaultUndoManager = nil + } operation: { + let db = try DatabaseQueue.undoDatabase() + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) + } + } + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + } + #endif +} From 2da7fa741b2d0db5ca78a849965960666ccc419c Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Sun, 22 Feb 2026 01:06:13 +0100 Subject: [PATCH 02/16] Fix tests and warnings --- Sources/SQLiteData/Undo/UndoManager.swift | 6 +++--- Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift | 5 ----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index 73b255bc..266c91a4 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -272,7 +272,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { return activeBarrier.barrier } let summary = try database.write { db -> (maxSeq: Int, modifiedTables: Set)? in - guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + guard var maxSeq = try UndoLog.order(by: { $0.seq.desc() }).fetchOne(db)?.seq, maxSeq >= barrier.firstLog else { return nil @@ -301,7 +301,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { return activeBarrier.barrier } let summary = try await database.write { db -> (maxSeq: Int, modifiedTables: Set)? in - guard var maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq, + guard var maxSeq = try UndoLog.order(by: { $0.seq.desc() }).fetchOne(db)?.seq, maxSeq >= barrier.firstLog else { return nil @@ -576,7 +576,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // The triggers fired during `applyInverse` will have added new rows to the log. let newEnd = try await database.write { db -> Int in - guard var newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq else { return 0 } + guard var newEnd = try UndoLog.order(by: { $0.seq.desc() }).fetchOne(db)?.seq else { return 0 } if newEnd >= firstLog { try undoReconcileEntries(in: db, from: firstLog, to: newEnd) newEnd = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index ff593ebe..54a3f018 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -23,11 +23,6 @@ import Testing var message: String } -@Table("parents") private struct Parent: Equatable, Identifiable { - let id: Int - var name: String -} - @Table("children") private struct Child: Equatable, Identifiable { let id: Int var parentID: Int From cb003b0d88dcf3b9e31eb39bf550049bba7ec12b Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Mon, 23 Feb 2026 23:18:20 +0100 Subject: [PATCH 03/16] Refactor undo SQL execution Use #sql macro-based execution/fetch paths in undo setup, replay, and trigger helpers for safer SQL handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Undo/Internal/UndoTriggers.swift | 51 ++++++++++--------- Sources/SQLiteData/Undo/UndoManager.swift | 8 +-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift index cf2faaf9..b0418454 100644 --- a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -1,5 +1,6 @@ import Foundation import GRDB +import StructuredQueriesCore // MARK: - Column info @@ -13,22 +14,24 @@ package func undoColumnNames(for tableName: String, in db: Database) throws -> [ // parameters in table-valued-function arguments. let tableLiteral = "'" + tableName.replacingOccurrences(of: "'", with: "''") + "'" do { - return try String.fetchAll( - db, - sql: """ - SELECT name FROM pragma_table_xinfo(\(tableLiteral)) + return try #sql( + """ + SELECT name FROM pragma_table_xinfo(\(raw: tableLiteral)) WHERE hidden = 0 ORDER BY cid - """ - ) + """, + as: String.self + ) + .fetchAll(db) } catch { - return try String.fetchAll( - db, - sql: """ - SELECT name FROM pragma_table_info(\(tableLiteral)) - ORDER BY cid + return try #sql( """ - ) + SELECT name FROM pragma_table_info(\(raw: tableLiteral)) + ORDER BY cid + """, + as: String.self + ) + .fetchAll(db) } } @@ -134,17 +137,14 @@ package func undoTriggerDropSQL(for tableName: String) -> [String] { // MARK: - Undo log analysis package func undoModifiedTableNames(in db: Database, from startSeq: Int, to endSeq: Int) throws -> Set { - Set( - try String.fetchAll( - db, - sql: """ - SELECT DISTINCT "tableName" - FROM "sqlitedata_undo_log" - WHERE "seq" >= ? AND "seq" <= ? - """, - arguments: [startSeq, endSeq] - ) - ) + Set(try #sql( + """ + SELECT DISTINCT "tableName" + FROM "sqlitedata_undo_log" + WHERE "seq" >= \(startSeq) AND "seq" <= \(endSeq) + """, + as: String.self + ).fetchAll(db)) } package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq: Int) throws { @@ -185,7 +185,10 @@ package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq guard !seqsToDelete.isEmpty else { return } let sqlList = seqsToDelete.map(String.init).joined(separator: ",") - try db.execute(sql: "DELETE FROM \"sqlitedata_undo_log\" WHERE \"seq\" IN (\(sqlList))") + try #sql( + #"DELETE FROM "sqlitedata_undo_log" WHERE "seq" IN (\#(raw: sqlList))"# + ) + .execute(db) } // MARK: - Helpers diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index 266c91a4..177c5f7c 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -167,14 +167,14 @@ public final class UndoManager: Perceptible, @unchecked Sendable { db.add(function: $_shouldRecord) db.add(function: $_isReplaying) - try db.execute(sql: undoLogTableSQL) + try #sql("\(raw: undoLogTableSQL)").execute(db) for table in repeat each tables { let tableName = table.tableName let columns = try undoColumnNames(for: tableName, in: db) guard !columns.isEmpty else { continue } for sql in undoTriggerSQL(for: tableName, columns: columns) { - try db.execute(sql: sql) + try #sql("\(raw: sql)").execute(db) } } } @@ -564,11 +564,11 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // Replayed statements can include child-before-parent row restoration from cascading // deletes. Deferring FK checks until commit lets the full inverse set restore first. - try db.execute(sql: "PRAGMA defer_foreign_keys = ON") + try #sql("PRAGMA defer_foreign_keys = ON").execute(db) // Execute each inverse SQL statement in order. for row in rows { - try db.execute(sql: row.sql) + try #sql("\(raw: row.sql)").execute(db) } return affectedRows } From f1b5883b7d461efee1cbfeaa50662efae7a70c95 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Mon, 23 Feb 2026 23:36:22 +0100 Subject: [PATCH 04/16] Refactor undo trigger SQL with StructuredQueries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Undo/Internal/UndoTriggers.swift | 255 +++++++++--------- Sources/SQLiteData/Undo/UndoManager.swift | 7 +- .../UndoTests/UndoManagerTests.swift | 5 +- 3 files changed, 128 insertions(+), 139 deletions(-) diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift index b0418454..e7348373 100644 --- a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -2,39 +2,6 @@ import Foundation import GRDB import StructuredQueriesCore -// MARK: - Column info - -/// Reads writable column names for `tableName`. -/// -/// Prefer `pragma_table_xinfo` so hidden/generated columns can be filtered with `hidden = 0`. -/// Fall back to `pragma_table_info` on older SQLite builds where `table_xinfo` is unavailable. -package func undoColumnNames(for tableName: String, in db: Database) throws -> [String] { - // Use pragma table-valued functions. The table name is embedded as a quoted - // SQL identifier, not as a bound parameter, because some SQLite versions do not support bound - // parameters in table-valued-function arguments. - let tableLiteral = "'" + tableName.replacingOccurrences(of: "'", with: "''") + "'" - do { - return try #sql( - """ - SELECT name FROM pragma_table_xinfo(\(raw: tableLiteral)) - WHERE hidden = 0 - ORDER BY cid - """, - as: String.self - ) - .fetchAll(db) - } catch { - return try #sql( - """ - SELECT name FROM pragma_table_info(\(raw: tableLiteral)) - ORDER BY cid - """, - as: String.self - ) - .fetchAll(db) - } -} - // MARK: - Undo log table /// The DDL that creates the per-connection temporary undo log table. @@ -47,104 +14,135 @@ package let undoLogTableSQL = """ ) """ -// MARK: - Trigger SQL - -/// Generates the three undo triggers for a single table. -/// -/// - Parameters: -/// - tableName: The name of the user table to observe. -/// - columns: The writable column names obtained from `undoColumnNames(for:in:)`. -/// - Returns: Three `CREATE TEMP TRIGGER` statements (insert, update, delete). -package func undoTriggerSQL(for tableName: String, columns: [String]) -> [String] { - let qt: String = undoDoubleQuotedIdentifier(tableName) - let logTable = "\"sqlitedata_undo_log\"" - let whenClause = "WHEN sqlitedata_undo_shouldRecord()" - let triggerPrefix = "_sqlitedata_undo_" - - // INSERT → log a DELETE that removes the new row - let insertTrigger = """ - CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)insert_\(tableName)")) - AFTER INSERT ON \(qt) - \(whenClause) - BEGIN - INSERT INTO \(logTable) VALUES( - NULL, - '\(tableName)', - NEW.rowid, - 'DELETE FROM \(qt) WHERE rowid='||NEW.rowid - ); - END - """ - - // UPDATE → log an UPDATE that restores all old column values - // Only fire when at least one column actually changed. - let changedCondition: String = columns - .map { col -> String in - let qc: String = undoDoubleQuotedIdentifier(col) - return "OLD.\(qc) IS NOT NEW.\(qc)" - } - .joined(separator: " OR ") - let setClause: String = columns - .map { col -> String in - let qc: String = undoDoubleQuotedIdentifier(col) - return "\(qc)='||quote(OLD.\(qc))||'" - } - .joined(separator: ",") - let updateTrigger = """ - CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)update_\(tableName)")) - BEFORE UPDATE ON \(qt) - WHEN \(whenClause.dropFirst("WHEN ".count)) AND (\(changedCondition)) - BEGIN - INSERT INTO \(logTable) VALUES( - NULL, - '\(tableName)', - OLD.rowid, - 'UPDATE \(qt) SET \(setClause) WHERE rowid='||OLD.rowid - ); - END - """ - - // DELETE → log an INSERT that restores the deleted row - let colList: String = columns.map { undoDoubleQuotedIdentifier($0) }.joined(separator: ",") - let valList: String = columns - .map { col -> String in "'||quote(OLD.\(undoDoubleQuotedIdentifier(col)))||'" } - .joined(separator: ",") - let deleteTrigger = """ - CREATE TEMP TRIGGER \(undoDoubleQuotedIdentifier("\(triggerPrefix)delete_\(tableName)")) - BEFORE DELETE ON \(qt) - \(whenClause) - BEGIN - INSERT INTO \(logTable) VALUES( - NULL, - '\(tableName)', - OLD.rowid, - 'INSERT INTO \(qt)(rowid,\(colList)) VALUES('||OLD.rowid||',\(valList))' - ); - END - """ - - return [insertTrigger, updateTrigger, deleteTrigger] -} +// MARK: - Trigger installation + +extension PrimaryKeyedTable { + package static func installUndoTriggers(in db: Database) throws { + guard !undoWritableColumnNames.isEmpty else { return } + try undoInsertTrigger.execute(db) + try undoUpdateTrigger.execute(db) + try undoDeleteTrigger.execute(db) + } + + fileprivate static var undoInsertTrigger: TemporaryTrigger { + createTemporaryTrigger( + "_sqlitedata_undo_insert_\(tableName)", + ifNotExists: true, + after: .insert { new in + UndoLog.insert { + ($0.tableName, $0.trackedRowID, $0.sql) + } select: { + Values( + tableName, + new.rowid, + #sql( + "'DELETE FROM \(raw: undoQuotedTableName) WHERE rowid=' || \(new.rowid)", + as: String.self + ) + ) + } + } when: { _ in + $_shouldRecord() + } + ) + } + + fileprivate static var undoUpdateTrigger: TemporaryTrigger { + createTemporaryTrigger( + "_sqlitedata_undo_update_\(tableName)", + ifNotExists: true, + before: .update { old, _ in + UndoLog.insert { + ($0.tableName, $0.trackedRowID, $0.sql) + } select: { + Values( + tableName, + old.rowid, + #sql( + "'UPDATE \(raw: undoQuotedTableName) SET \(raw: undoSetClause) WHERE rowid=' || \(old.rowid)", + as: String.self + ) + ) + } + } when: { _, _ in + $_shouldRecord() && #sql("\(raw: undoChangedCondition)", as: Bool.self) + } + ) + } + + fileprivate static var undoDeleteTrigger: TemporaryTrigger { + createTemporaryTrigger( + "_sqlitedata_undo_delete_\(tableName)", + ifNotExists: true, + before: .delete { old in + UndoLog.insert { + ($0.tableName, $0.trackedRowID, $0.sql) + } select: { + Values( + tableName, + old.rowid, + #sql( + "'INSERT INTO \(raw: undoQuotedTableName)(rowid,\(raw: undoColumnList)) VALUES(' || \(old.rowid) || ',\(raw: undoValueList))'", + as: String.self + ) + ) + } + } when: { _ in + $_shouldRecord() + } + ) + } + + fileprivate static var undoWritableColumnNames: [String] { + Self.TableColumns.writableColumns.map(\.name) + } -/// Drop SQL for the three undo triggers of a table. -package func undoTriggerDropSQL(for tableName: String) -> [String] { - let prefix = "_sqlitedata_undo_" - return ["insert", "update", "delete"].map { kind in - "DROP TEMP TRIGGER IF EXISTS \(undoDoubleQuotedIdentifier("\(prefix)\(kind)_\(tableName)"))" + fileprivate static var undoQuotedTableName: String { + undoDoubleQuotedIdentifier(tableName) + } + + fileprivate static var undoChangedCondition: String { + undoWritableColumnNames + .map { column in + let columnIdentifier = undoDoubleQuotedIdentifier(column) + return "OLD.\(columnIdentifier) IS NOT NEW.\(columnIdentifier)" + } + .joined(separator: " OR ") + } + + fileprivate static var undoSetClause: String { + undoWritableColumnNames + .map { column in + let columnIdentifier = undoDoubleQuotedIdentifier(column) + return "\(columnIdentifier)='||quote(OLD.\(columnIdentifier))||'" + } + .joined(separator: ",") + } + + fileprivate static var undoColumnList: String { + undoWritableColumnNames + .map(undoDoubleQuotedIdentifier) + .joined(separator: ",") + } + + fileprivate static var undoValueList: String { + undoWritableColumnNames + .map { column in + "'||quote(OLD.\(undoDoubleQuotedIdentifier(column)))||'" + } + .joined(separator: ",") } } // MARK: - Undo log analysis package func undoModifiedTableNames(in db: Database, from startSeq: Int, to endSeq: Int) throws -> Set { - Set(try #sql( - """ - SELECT DISTINCT "tableName" - FROM "sqlitedata_undo_log" - WHERE "seq" >= \(startSeq) AND "seq" <= \(endSeq) - """, - as: String.self - ).fetchAll(db)) + Set( + try UndoLog + .where { $0.seq >= startSeq && $0.seq <= endSeq } + .select(\.tableName) + .fetchAll(db) + ) } package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq: Int) throws { @@ -184,11 +182,10 @@ package func undoReconcileEntries(in db: Database, from startSeq: Int, to endSeq } guard !seqsToDelete.isEmpty else { return } - let sqlList = seqsToDelete.map(String.init).joined(separator: ",") - try #sql( - #"DELETE FROM "sqlitedata_undo_log" WHERE "seq" IN (\#(raw: sqlList))"# - ) - .execute(db) + try UndoLog + .where { $0.seq.in(seqsToDelete) } + .delete() + .execute(db) } // MARK: - Helpers diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index 177c5f7c..be2fc19c 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -170,12 +170,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { try #sql("\(raw: undoLogTableSQL)").execute(db) for table in repeat each tables { - let tableName = table.tableName - let columns = try undoColumnNames(for: tableName, in: db) - guard !columns.isEmpty else { continue } - for sql in undoTriggerSQL(for: tableName, columns: columns) { - try #sql("\(raw: sql)").execute(db) - } + try table.installUndoTriggers(in: db) } } diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 54a3f018..7822cd3d 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -382,10 +382,7 @@ extension DatabaseWriter where Self == DatabaseQueue { ) """ ) - let columns = try undoColumnNames(for: "audits", in: db) - for sql in undoTriggerSQL(for: "audits", columns: columns) { - try db.execute(sql: sql) - } + try Audit.installUndoTriggers(in: db) } try await withKnownIssue { From 7747e3a4f8c41d610c6897f40fd6a83f2f1c60cf Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Tue, 24 Feb 2026 00:07:08 +0100 Subject: [PATCH 05/16] Add LocalizedStringKey undo overloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/Reminders/ReminderForm.swift | 7 +- Examples/Reminders/ReminderRow.swift | 10 +- Examples/Reminders/RemindersDetail.swift | 2 +- Examples/Reminders/RemindersListForm.swift | 4 +- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 21 ++- Examples/Reminders/SearchReminders.swift | 2 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 8 +- .../SQLiteData/Undo/DatabaseWriter+Undo.swift | 73 +++++++++ Sources/SQLiteData/Undo/UndoManager.swift | 152 ++++++++++++++++++ 11 files changed, 267 insertions(+), 16 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 8e34af64..2dbb4ae5 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -169,8 +169,11 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { - try database.writeWithUndoGroup(reminder.id == nil ? "Create reminder" : "Edit reminder") { - db in + try database.writeWithUndoGroup( + reminder.id == nil + ? LocalizedStringKey("Create reminder") + : LocalizedStringKey("Edit reminder") + ) { db in let reminderID = try Reminder.upsert { reminder } .returning(\.id) .fetchOne(db)! diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 9e3b3567..02f6f6f0 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -76,7 +76,7 @@ struct ReminderRow: View { .swipeActions { Button("Delete", role: .destructive) { withErrorReporting { - try database.writeWithUndoGroup("Delete reminder") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete reminder")) { db in try Reminder.delete(reminder).execute(db) } } @@ -84,7 +84,9 @@ struct ReminderRow: View { Button(reminder.isFlagged ? "Unflag" : "Flag") { withErrorReporting { try database.writeWithUndoGroup( - reminder.isFlagged ? "Unflag reminder" : "Flag reminder" + reminder.isFlagged + ? LocalizedStringKey("Unflag reminder") + : LocalizedStringKey("Flag reminder") ) { db in try Reminder .find(reminder.id) @@ -109,7 +111,9 @@ struct ReminderRow: View { private func completeButtonTapped() { withErrorReporting { try database.writeWithUndoGroup( - reminder.isCompleted ? "Mark reminder incomplete" : "Mark reminder complete" + reminder.isCompleted + ? LocalizedStringKey("Mark reminder incomplete") + : LocalizedStringKey("Mark reminder complete") ) { db in try Reminder .find(reminder.id) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index a8a095a0..8ac468bd 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -50,7 +50,7 @@ class RemindersDetailModel: HashableObject { func move(from source: IndexSet, to destination: Int) async { withErrorReporting { - try database.writeWithUndoGroup("Reorder reminders") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Reorder reminders")) { db in var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 44dc7df8..c635b614 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -78,7 +78,9 @@ struct RemindersListForm: View { Task { [remindersList, coverImageData] in await withErrorReporting { try await database.writeWithUndoGroup( - remindersList.id == nil ? "Create list" : "Edit list" + remindersList.id == nil + ? LocalizedStringKey("Create list") + : LocalizedStringKey("Edit list") ) { db in let remindersListID = try RemindersList diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 8f4e6cbf..0786c3e2 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -35,7 +35,7 @@ struct RemindersListRow: View { .swipeActions { Button { withErrorReporting { - try database.writeWithUndoGroup("Delete list") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete list")) { db in try RemindersList.delete(remindersList) .execute(db) } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 4a3d9ece..788b40f0 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -85,7 +85,7 @@ class RemindersListsModel { func deleteTags(atOffsets offsets: IndexSet) { withErrorReporting { let tagTitles = offsets.map { tags[$0].title } - try database.writeWithUndoGroup("Delete tags") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete tags")) { db in try Tag .where { $0.title.in(tagTitles) } .delete() @@ -126,7 +126,7 @@ class RemindersListsModel { func move(from source: IndexSet, to destination: Int) { withErrorReporting { - try database.writeWithUndoGroup("Reorder lists") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Reorder lists")) { db in var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) try RemindersList @@ -148,8 +148,21 @@ class RemindersListsModel { #if DEBUG func seedDatabaseButtonTapped() { - withErrorReporting { - try database.seedSampleData() + Task { + await withErrorReporting { + if let undoManager { + try await undoManager.freeze() + do { + try database.seedSampleData() + try await undoManager.unfreeze() + } catch { + try await undoManager.unfreeze() + throw error + } + } else { + try database.seedSampleData() + } + } } } #endif diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 0fce00c6..b9a8a6fd 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -64,7 +64,7 @@ class SearchRemindersModel { func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { - try database.writeWithUndoGroup("Clear completed reminders") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Clear completed reminders")) { db in try Reminder .where { $0.isCompleted diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index 32444e5a..adb801b2 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -18,7 +18,7 @@ struct TagRow: View { .swipeActions { Button { withErrorReporting { - try database.writeWithUndoGroup("Delete tag") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete tag")) { db in try Tag.delete(tag) .execute(db) } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 989c52dc..7dbc9df9 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -80,7 +80,7 @@ struct TagsView: View { func deleteButtonTapped(tag: Tag) { withErrorReporting { - try database.writeWithUndoGroup("Delete tag") { db in + try database.writeWithUndoGroup(LocalizedStringKey("Delete tag")) { db in try Tag.find(tag.title).delete().execute(db) } } @@ -95,7 +95,11 @@ struct TagsView: View { defer { tagTitle = "" } let tag = Tag(title: tagTitle) withErrorReporting { - try database.writeWithUndoGroup(editingTag == nil ? "Create tag" : "Edit tag") { db in + try database.writeWithUndoGroup( + editingTag == nil + ? LocalizedStringKey("Create tag") + : LocalizedStringKey("Edit tag") + ) { db in if let existingTagTitle = editingTag?.title { selectedTags.removeAll(where: { $0.title == existingTagTitle }) try Tag diff --git a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift index 4735a3ee..0db10bfa 100644 --- a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift +++ b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift @@ -1,4 +1,43 @@ +import Foundation +#if canImport(SwiftUI) + import SwiftUI +#endif + public extension DatabaseWriter { + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + func writeWithUndoGroup( + _ description: LocalizedStringKey, + _ updates: (Database) throws -> T + ) throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try undoManager.withGroup(description, updates) + } + return try write(updates) + } + #endif + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @_disfavoredOverload + func writeWithUndoGroup( + _ description: LocalizedStringResource, + _ updates: (Database) throws -> T + ) throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try undoManager.withGroup(description, updates) + } + return try write(updates) + } + + @_disfavoredOverload func writeWithUndoGroup( _ description: String, _ updates: (Database) throws -> T @@ -13,6 +52,40 @@ public extension DatabaseWriter { return try write(updates) } + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + func writeWithUndoGroup( + _ description: LocalizedStringKey, + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try await undoManager.withGroup(description, updates) + } + return try await write(updates) + } + #endif + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + func writeWithUndoGroup( + _ description: LocalizedStringResource, + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.defaultUndoManager) var defaultUndoManager + let undoManager = + (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) + ?? UndoManager.manager(for: self) + if let undoManager { + return try await undoManager.withGroup(description, updates) + } + return try await write(updates) + } + + @_disfavoredOverload func writeWithUndoGroup( _ description: String, _ updates: @Sendable (Database) throws -> T diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index be2fc19c..d50c4c4d 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -6,6 +6,9 @@ import Perception #if canImport(Observation) import Observation #endif +#if canImport(SwiftUI) + import SwiftUI +#endif import StructuredQueriesCore #if canImport(UIKit) @@ -230,9 +233,47 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // MARK: - Group recording + /// Begins recording a barrier that can later be ended or cancelled. + /// + /// This overload accepts a localized resource so group names can participate in localization. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @discardableResult + public func beginBarrier( + _ description: LocalizedStringResource, + deviceID: String? = nil, + userRecordName: String? = nil + ) throws -> UUID { + try beginBarrier( + String(localized: description), + deviceID: deviceID, + userRecordName: userRecordName + ) + } + + #if canImport(SwiftUI) + /// Begins recording a barrier that can later be ended or cancelled. + /// + /// This overload accepts a localized key and resolves it using the app's main bundle. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + @discardableResult + public func beginBarrier( + _ description: LocalizedStringKey, + deviceID: String? = nil, + userRecordName: String? = nil + ) throws -> UUID { + try beginBarrier( + description.undoGroupKeyString, + deviceID: deviceID, + userRecordName: userRecordName + ) + } + #endif + /// Begins recording a barrier that can later be ended or cancelled. /// /// Use this API when an undoable action spans multiple writes or async boundaries. + @_disfavoredOverload @discardableResult public func beginBarrier( _ description: String, @@ -366,6 +407,66 @@ public final class UndoManager: Perceptible, @unchecked Sendable { /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. /// - body: A closure that performs database writes. Receives a `Database` connection. /// - Returns: The value returned by `body`. + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + @discardableResult + public func withGroup( + _ description: LocalizedStringKey, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: @Sendable (Database) throws -> T + ) async throws -> T { + try await withGroup( + description.undoGroupKeyString, + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + #endif + + /// Performs `body` inside a database write transaction and records all changes as a named + /// undo group. + /// + /// If `body` makes no changes (or triggers are suppressed because recording is frozen), no + /// undo entry is added. + /// + /// Calling this method clears the redo stack. + /// + /// - Parameters: + /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. + /// - body: A closure that performs database writes. Receives a `Database` connection. + /// - Returns: The value returned by `body`. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @discardableResult + public func withGroup( + _ description: LocalizedStringResource, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: @Sendable (Database) throws -> T + ) async throws -> T { + try await withGroup( + String(localized: description), + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + + /// Performs `body` inside a database write transaction and records all changes as a named + /// undo group. + /// + /// If `body` makes no changes (or triggers are suppressed because recording is frozen), no + /// undo entry is added. + /// + /// Calling this method clears the redo stack. + /// + /// - Parameters: + /// - description: A human-readable label for the change, e.g. `"Delete reminder"`. + /// - body: A closure that performs database writes. Receives a `Database` connection. + /// - Returns: The value returned by `body`. + @_disfavoredOverload @discardableResult public func withGroup( _ description: String, @@ -391,6 +492,44 @@ public final class UndoManager: Perceptible, @unchecked Sendable { } /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + #if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + @_disfavoredOverload + @discardableResult + public func withGroup( + _ description: LocalizedStringKey, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: (Database) throws -> T + ) throws -> T { + try withGroup( + description.undoGroupKeyString, + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + #endif + + /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @discardableResult + public func withGroup( + _ description: LocalizedStringResource, + deviceID: String? = nil, + userRecordName: String? = nil, + _ body: (Database) throws -> T + ) throws -> T { + try withGroup( + String(localized: description), + deviceID: deviceID, + userRecordName: userRecordName, + body + ) + } + + /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + @_disfavoredOverload @discardableResult public func withGroup( _ description: String, @@ -655,6 +794,19 @@ public final class UndoManager: Perceptible, @unchecked Sendable { #endif } +#if canImport(SwiftUI) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + private extension LocalizedStringKey { + var undoGroupKeyString: String { + Mirror(reflecting: self) + .children + .first { $0.label == "key" } + .flatMap { $0.value as? String } + ?? String(describing: self) + } + } +#endif + #if canImport(Observation) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension UndoManager: Observable {} From e61a14ac7927b97c0b64518fa05770b265136520 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Tue, 24 Feb 2026 00:11:04 +0100 Subject: [PATCH 06/16] Fix temp trigger undo log writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SQLiteData/Undo/Internal/UndoTriggers.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift index e7348373..5518ff59 100644 --- a/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift +++ b/Sources/SQLiteData/Undo/Internal/UndoTriggers.swift @@ -4,6 +4,15 @@ import StructuredQueriesCore // MARK: - Undo log table +/// Trigger-local mapping to avoid qualifying temp table names inside trigger DML. +@Table("sqlitedata_undo_log") +private struct TriggerUndoLog { + let seq: Int + let tableName: String + let trackedRowID: Int + let sql: String +} + /// The DDL that creates the per-connection temporary undo log table. package let undoLogTableSQL = """ CREATE TEMP TABLE IF NOT EXISTS "sqlitedata_undo_log" ( @@ -29,7 +38,7 @@ extension PrimaryKeyedTable { "_sqlitedata_undo_insert_\(tableName)", ifNotExists: true, after: .insert { new in - UndoLog.insert { + TriggerUndoLog.insert { ($0.tableName, $0.trackedRowID, $0.sql) } select: { Values( @@ -52,7 +61,7 @@ extension PrimaryKeyedTable { "_sqlitedata_undo_update_\(tableName)", ifNotExists: true, before: .update { old, _ in - UndoLog.insert { + TriggerUndoLog.insert { ($0.tableName, $0.trackedRowID, $0.sql) } select: { Values( @@ -75,7 +84,7 @@ extension PrimaryKeyedTable { "_sqlitedata_undo_delete_\(tableName)", ifNotExists: true, before: .delete { old in - UndoLog.insert { + TriggerUndoLog.insert { ($0.tableName, $0.trackedRowID, $0.sql) } select: { Values( From fb044badeb41a4a6ef91a10d09c9e3f1584bdc73 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Tue, 24 Feb 2026 00:23:09 +0100 Subject: [PATCH 07/16] Cleanup LocalizedStringKey --- Examples/Reminders/ReminderForm.swift | 4 ++-- Examples/Reminders/ReminderRow.swift | 10 +++++----- Examples/Reminders/RemindersDetail.swift | 2 +- Examples/Reminders/RemindersListForm.swift | 4 ++-- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 4 ++-- Examples/Reminders/SearchReminders.swift | 2 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 6 +++--- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 2dbb4ae5..c9f2544d 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -171,8 +171,8 @@ struct ReminderFormView: View { withErrorReporting { try database.writeWithUndoGroup( reminder.id == nil - ? LocalizedStringKey("Create reminder") - : LocalizedStringKey("Edit reminder") + ? "Create reminder" + : "Edit reminder" ) { db in let reminderID = try Reminder.upsert { reminder } .returning(\.id) diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 02f6f6f0..07130879 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -76,7 +76,7 @@ struct ReminderRow: View { .swipeActions { Button("Delete", role: .destructive) { withErrorReporting { - try database.writeWithUndoGroup(LocalizedStringKey("Delete reminder")) { db in + try database.writeWithUndoGroup("Delete reminder") { db in try Reminder.delete(reminder).execute(db) } } @@ -85,8 +85,8 @@ struct ReminderRow: View { withErrorReporting { try database.writeWithUndoGroup( reminder.isFlagged - ? LocalizedStringKey("Unflag reminder") - : LocalizedStringKey("Flag reminder") + ? "Unflag reminder" + : "Flag reminder" ) { db in try Reminder .find(reminder.id) @@ -112,8 +112,8 @@ struct ReminderRow: View { withErrorReporting { try database.writeWithUndoGroup( reminder.isCompleted - ? LocalizedStringKey("Mark reminder incomplete") - : LocalizedStringKey("Mark reminder complete") + ? "Mark reminder incomplete" + : "Mark reminder complete" ) { db in try Reminder .find(reminder.id) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 8ac468bd..a8a095a0 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -50,7 +50,7 @@ class RemindersDetailModel: HashableObject { func move(from source: IndexSet, to destination: Int) async { withErrorReporting { - try database.writeWithUndoGroup(LocalizedStringKey("Reorder reminders")) { db in + try database.writeWithUndoGroup("Reorder reminders") { db in var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index c635b614..545ead07 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -79,8 +79,8 @@ struct RemindersListForm: View { await withErrorReporting { try await database.writeWithUndoGroup( remindersList.id == nil - ? LocalizedStringKey("Create list") - : LocalizedStringKey("Edit list") + ? "Create list" + : "Edit list" ) { db in let remindersListID = try RemindersList diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 0786c3e2..8f4e6cbf 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -35,7 +35,7 @@ struct RemindersListRow: View { .swipeActions { Button { withErrorReporting { - try database.writeWithUndoGroup(LocalizedStringKey("Delete list")) { db in + try database.writeWithUndoGroup("Delete list") { db in try RemindersList.delete(remindersList) .execute(db) } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 788b40f0..6181d8c4 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -85,7 +85,7 @@ class RemindersListsModel { func deleteTags(atOffsets offsets: IndexSet) { withErrorReporting { let tagTitles = offsets.map { tags[$0].title } - try database.writeWithUndoGroup(LocalizedStringKey("Delete tags")) { db in + try database.writeWithUndoGroup("Delete tags") { db in try Tag .where { $0.title.in(tagTitles) } .delete() @@ -126,7 +126,7 @@ class RemindersListsModel { func move(from source: IndexSet, to destination: Int) { withErrorReporting { - try database.writeWithUndoGroup(LocalizedStringKey("Reorder lists")) { db in + try database.writeWithUndoGroup("Reorder lists") { db in var ids = remindersLists.map(\.remindersList.id) ids.move(fromOffsets: source, toOffset: destination) try RemindersList diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index b9a8a6fd..0fce00c6 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -64,7 +64,7 @@ class SearchRemindersModel { func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { - try database.writeWithUndoGroup(LocalizedStringKey("Clear completed reminders")) { db in + try database.writeWithUndoGroup("Clear completed reminders") { db in try Reminder .where { $0.isCompleted diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index adb801b2..32444e5a 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -18,7 +18,7 @@ struct TagRow: View { .swipeActions { Button { withErrorReporting { - try database.writeWithUndoGroup(LocalizedStringKey("Delete tag")) { db in + try database.writeWithUndoGroup("Delete tag") { db in try Tag.delete(tag) .execute(db) } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 7dbc9df9..84858772 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -80,7 +80,7 @@ struct TagsView: View { func deleteButtonTapped(tag: Tag) { withErrorReporting { - try database.writeWithUndoGroup(LocalizedStringKey("Delete tag")) { db in + try database.writeWithUndoGroup("Delete tag") { db in try Tag.find(tag.title).delete().execute(db) } } @@ -97,8 +97,8 @@ struct TagsView: View { withErrorReporting { try database.writeWithUndoGroup( editingTag == nil - ? LocalizedStringKey("Create tag") - : LocalizedStringKey("Edit tag") + ? "Create tag" + : "Edit tag" ) { db in if let existingTagTitle = editingTag?.title { selectedTags.removeAll(where: { $0.title == existingTagTitle }) From 64304870519c3cb76200b62e3de438e16a1fee01 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Tue, 24 Feb 2026 00:57:06 +0100 Subject: [PATCH 08/16] Fix undo delegate --- Sources/SQLiteData/Undo/UndoManagerDelegate.swift | 2 ++ Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Sources/SQLiteData/Undo/UndoManagerDelegate.swift b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift index 5f2eaa3e..572bcd10 100644 --- a/Sources/SQLiteData/Undo/UndoManagerDelegate.swift +++ b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift @@ -13,6 +13,7 @@ public protocol UndoManagerDelegate: AnyObject, Sendable { /// - action: Whether this is an undo or redo. /// - group: The group of changes that will be undone or redone. /// - performAction: Call this to commit the operation. Omitting this call cancels it. + @MainActor func undoManager( _ undoManager: SQLiteData.UndoManager, willPerform action: UndoAction, @@ -23,6 +24,7 @@ public protocol UndoManagerDelegate: AnyObject, Sendable { extension UndoManagerDelegate { /// Default implementation: immediately performs the action without confirmation. + @MainActor public func undoManager( _ undoManager: SQLiteData.UndoManager, willPerform action: UndoAction, diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 7822cd3d..bab05348 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -396,6 +396,7 @@ extension DatabaseWriter where Self == DatabaseQueue { } @Test func delegateCancel() async throws { + @MainActor final class CancelDelegate: UndoManagerDelegate { func undoManager( _ undoManager: SQLiteData.UndoManager, @@ -432,6 +433,7 @@ extension DatabaseWriter where Self == DatabaseQueue { } let capture = MetadataCapture() + @MainActor final class MetadataDelegate: UndoManagerDelegate, @unchecked Sendable { let capture: MetadataCapture init(_ capture: MetadataCapture) { self.capture = capture } @@ -473,6 +475,7 @@ extension DatabaseWriter where Self == DatabaseQueue { } let capture = ActionCapture() + @MainActor final class ActionDelegate: UndoManagerDelegate, @unchecked Sendable { let capture: ActionCapture init(_ capture: ActionCapture) { self.capture = capture } From 5f110a10fb3778580f46613bdcb17a60bcbea244 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Tue, 24 Feb 2026 01:35:41 +0100 Subject: [PATCH 09/16] Fix undo delegate witness matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/Reminders/RemindersApp.swift | 4 ++-- .../SQLiteData/Undo/UndoManagerDelegate.swift | 16 ---------------- .../UndoTests/UndoManagerTests.swift | 3 --- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 0b83d240..2a1c7dd0 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -89,7 +89,7 @@ class RemindersSyncEngineDelegate: SyncEngineDelegate { @MainActor @Observable -final class RemindersUndoManagerDelegate: UndoManagerDelegate { +final class RemindersUndoManagerDelegate: SQLiteData.UndoManagerDelegate { struct ConfirmationRequest: Identifiable { let action: UndoAction let group: UndoGroup @@ -135,7 +135,7 @@ final class RemindersUndoManagerDelegate: UndoManagerDelegate { _ undoManager: SQLiteData.UndoManager, willPerform action: UndoAction, for group: UndoGroup, - performAction: @Sendable () async throws -> Void + performAction: @isolated(any) @Sendable () async throws -> Void ) async throws { guard shouldConfirm(for: group) else { try await performAction() diff --git a/Sources/SQLiteData/Undo/UndoManagerDelegate.swift b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift index 572bcd10..fe6039c9 100644 --- a/Sources/SQLiteData/Undo/UndoManagerDelegate.swift +++ b/Sources/SQLiteData/Undo/UndoManagerDelegate.swift @@ -3,8 +3,6 @@ /// The delegate can present a confirmation prompt, or perform any async work, before calling /// `performAction` to commit the operation. If `performAction` is not called, the operation is /// cancelled and the undo/redo stacks remain unchanged. -/// -/// The default implementation performs the action immediately without any confirmation. public protocol UndoManagerDelegate: AnyObject, Sendable { /// Called before the undo manager performs an undo or redo operation. /// @@ -13,7 +11,6 @@ public protocol UndoManagerDelegate: AnyObject, Sendable { /// - action: Whether this is an undo or redo. /// - group: The group of changes that will be undone or redone. /// - performAction: Call this to commit the operation. Omitting this call cancels it. - @MainActor func undoManager( _ undoManager: SQLiteData.UndoManager, willPerform action: UndoAction, @@ -21,16 +18,3 @@ public protocol UndoManagerDelegate: AnyObject, Sendable { performAction: @Sendable () async throws -> Void ) async throws } - -extension UndoManagerDelegate { - /// Default implementation: immediately performs the action without confirmation. - @MainActor - public func undoManager( - _ undoManager: SQLiteData.UndoManager, - willPerform action: UndoAction, - for group: UndoGroup, - performAction: @Sendable () async throws -> Void - ) async throws { - try await performAction() - } -} diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index bab05348..7822cd3d 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -396,7 +396,6 @@ extension DatabaseWriter where Self == DatabaseQueue { } @Test func delegateCancel() async throws { - @MainActor final class CancelDelegate: UndoManagerDelegate { func undoManager( _ undoManager: SQLiteData.UndoManager, @@ -433,7 +432,6 @@ extension DatabaseWriter where Self == DatabaseQueue { } let capture = MetadataCapture() - @MainActor final class MetadataDelegate: UndoManagerDelegate, @unchecked Sendable { let capture: MetadataCapture init(_ capture: MetadataCapture) { self.capture = capture } @@ -475,7 +473,6 @@ extension DatabaseWriter where Self == DatabaseQueue { } let capture = ActionCapture() - @MainActor final class ActionDelegate: UndoManagerDelegate, @unchecked Sendable { let capture: ActionCapture init(_ capture: ActionCapture) { self.capture = capture } From 1ef36dec997c504599b7483b00865c204f134999 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Fri, 27 Feb 2026 00:23:14 +0100 Subject: [PATCH 10/16] Simplify undo group origin metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/Reminders/RemindersApp.swift | 22 ++--- .../CloudKit/Internal/UserDatabase.swift | 15 +-- Sources/SQLiteData/Undo/UndoGroup.swift | 21 +++-- Sources/SQLiteData/Undo/UndoManager.swift | 91 +++++-------------- .../UndoTests/UndoManagerTests.swift | 14 +-- 5 files changed, 47 insertions(+), 116 deletions(-) diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 2a1c7dd0..0a812781 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -111,20 +111,12 @@ final class RemindersUndoManagerDelegate: SQLiteData.UndoManagerDelegate { } private var originDescription: String { - var parts: [String] = [] - if group.deviceID != SQLiteUndoManager.defaultDeviceID { - if group.deviceID == "sqlitedata-sync" { - parts.append("another device") - } else { - parts.append("device \(group.deviceID)") - } - } - if - let userRecordName = group.userRecordName - { - parts.append("user \(userRecordName)") + switch group.origin { + case .local: + return "this device" + case .sync: + return "syncing" } - return parts.isEmpty ? "this device" : parts.joined(separator: " and ") } } @@ -153,9 +145,7 @@ final class RemindersUndoManagerDelegate: SQLiteData.UndoManagerDelegate { } private func shouldConfirm(for group: UndoGroup) -> Bool { - let isOtherDevice = group.deviceID != SQLiteUndoManager.defaultDeviceID - let isOtherUser = group.userRecordName != nil - return isOtherDevice || isOtherUser + group.origin == .sync } private func requestConfirmation(action: UndoAction, group: UndoGroup) async -> Bool { diff --git a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift index e1950a5c..43f7162e 100644 --- a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift @@ -1,5 +1,4 @@ #if canImport(CloudKit) - import CloudKit import Dependencies package struct UserDatabase { @@ -26,8 +25,7 @@ if let undoManager { return try await undoManager.withGroup( "Sync iCloud changes", - deviceID: UndoManager.syncDeviceID, - userRecordName: syncUndoUserRecordName + origin: .sync ) { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) @@ -60,8 +58,7 @@ if let undoManager { return try undoManager.withGroup( "Sync iCloud changes", - deviceID: UndoManager.syncDeviceID, - userRecordName: syncUndoUserRecordName + origin: .sync ) { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) @@ -83,13 +80,5 @@ try updates(db) } } - - private var syncUndoUserRecordName: String? { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - return _currentZoneID?.ownerName - } else { - return nil - } - } } #endif diff --git a/Sources/SQLiteData/Undo/UndoGroup.swift b/Sources/SQLiteData/Undo/UndoGroup.swift index b935cfda..ff5b564d 100644 --- a/Sources/SQLiteData/Undo/UndoGroup.swift +++ b/Sources/SQLiteData/Undo/UndoGroup.swift @@ -2,29 +2,32 @@ import Foundation /// A named group of database changes that can be undone or redone as a single unit. public struct UndoGroup: Sendable, Identifiable, Equatable { + /// Indicates where the change originated. + public enum Origin: String, Sendable, Equatable { + /// A change originated from local edits in this app instance. + case local + /// A change originated from synced remote updates. + case sync + } + /// A unique identifier for this group. public let id: UUID /// A human-readable description of the change, e.g. "Add reminder". public let description: String - /// An identifier for the device that originated the change. - public let deviceID: String - /// The iCloud record name of the user who made the change, or `nil` if this is the current user - /// or sync is not configured. - public let userRecordName: String? + /// Whether the change came from local edits or synced updates. + public let origin: Origin /// The date the change was recorded. public let date: Date package init( id: UUID = UUID(), description: String, - deviceID: String, - userRecordName: String?, + origin: Origin, date: Date ) { self.id = id self.description = description - self.deviceID = deviceID - self.userRecordName = userRecordName + self.origin = origin self.date = date } } diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index d50c4c4d..2eda59b2 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -11,12 +11,6 @@ import Perception #endif import StructuredQueriesCore -#if canImport(UIKit) - import UIKit -#elseif canImport(AppKit) - import AppKit -#endif - /// Tracks changes made to a SQLite database and lets you undo and redo them. /// /// Prefer ``SQLiteUndoManager`` in your code when you also work with `Foundation.UndoManager`. @@ -28,8 +22,7 @@ import StructuredQueriesCore /// ```swift /// let undoManager = try UndoManager( /// for: database, -/// tables: Reminder.self, ReminderTag.self, -/// deviceID: UIDevice.current.identifierForVendor?.uuidString ?? "" +/// tables: Reminder.self, ReminderTag.self /// ) /// /// // Record a named group of changes @@ -54,8 +47,6 @@ public final class UndoManager: Perceptible, @unchecked Sendable { } private static let _managersByID = LockIsolated([ObjectIdentifier: WeakUndoManager]()) - package static let syncDeviceID = "sqlitedata-sync" - // MARK: - Internal state private struct State { @@ -83,8 +74,6 @@ public final class UndoManager: Perceptible, @unchecked Sendable { private let _state = LockIsolated(State()) private let database: any DatabaseWriter private let databaseID: ObjectIdentifier - private let deviceID: String - private let userRecordName: @Sendable () -> String? private let trackedTableNames: Set private let delegate: (any UndoManagerDelegate)? private let eventsContinuation: AsyncStream.Continuation @@ -139,17 +128,12 @@ public final class UndoManager: Perceptible, @unchecked Sendable { /// - Parameters: /// - database: The database to observe. /// - tables: The names of the tables whose changes should be undoable. - /// - deviceID: An identifier for this device shown in ``UndoGroup/deviceID``. - /// Defaults to the system device identifier. - /// - userRecordName: A closure returning the current user's iCloud record name, or `nil`. /// - delegate: An optional delegate that can intercept and confirm undo/redo operations. public init< each T: PrimaryKeyedTable & _SendableMetatype >( for database: any DatabaseWriter, tables: repeat (each T).Type, - deviceID: String = UndoManager.defaultDeviceID, - userRecordName: @Sendable @escaping () -> String? = { nil }, delegate: (any UndoManagerDelegate)? = nil ) throws { var trackedTableNames = Set() @@ -159,8 +143,6 @@ public final class UndoManager: Perceptible, @unchecked Sendable { (self.events, self.eventsContinuation) = AsyncStream.makeStream() self.database = database self.databaseID = ObjectIdentifier(database as AnyObject) - self.deviceID = deviceID - self.userRecordName = userRecordName self.delegate = delegate self.trackedTableNames = trackedTableNames @@ -213,17 +195,6 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // MARK: - Static helpers - /// A device identifier suitable for use with ``init(for:tables:deviceID:userRecordName:delegate:)``. - /// - /// On iOS this is `UIDevice.identifierForVendor`; on macOS it is the machine's host name. - public static var defaultDeviceID: String { - #if canImport(UIKit) - return UIDevice.current.identifierForVendor?.uuidString ?? ProcessInfo.processInfo.hostName - #else - return ProcessInfo.processInfo.hostName - #endif - } - /// A SQL expression that reports whether undo/redo replay is currently executing. /// /// Use this in application trigger `WHEN` clauses to suppress side-effect writes during replay. @@ -240,13 +211,11 @@ public final class UndoManager: Perceptible, @unchecked Sendable { @discardableResult public func beginBarrier( _ description: LocalizedStringResource, - deviceID: String? = nil, - userRecordName: String? = nil + origin: UndoGroup.Origin = .local ) throws -> UUID { try beginBarrier( String(localized: description), - deviceID: deviceID, - userRecordName: userRecordName + origin: origin ) } @@ -259,13 +228,11 @@ public final class UndoManager: Perceptible, @unchecked Sendable { @discardableResult public func beginBarrier( _ description: LocalizedStringKey, - deviceID: String? = nil, - userRecordName: String? = nil + origin: UndoGroup.Origin = .local ) throws -> UUID { try beginBarrier( description.undoGroupKeyString, - deviceID: deviceID, - userRecordName: userRecordName + origin: origin ) } #endif @@ -277,13 +244,11 @@ public final class UndoManager: Perceptible, @unchecked Sendable { @discardableResult public func beginBarrier( _ description: String, - deviceID: String? = nil, - userRecordName: String? = nil + origin: UndoGroup.Origin = .local ) throws -> UUID { let group = UndoGroup( description: description, - deviceID: deviceID ?? self.deviceID, - userRecordName: userRecordName ?? self.userRecordName(), + origin: origin, date: Date() ) let barrierID = UUID() @@ -413,14 +378,12 @@ public final class UndoManager: Perceptible, @unchecked Sendable { @discardableResult public func withGroup( _ description: LocalizedStringKey, - deviceID: String? = nil, - userRecordName: String? = nil, + origin: UndoGroup.Origin = .local, _ body: @Sendable (Database) throws -> T ) async throws -> T { try await withGroup( description.undoGroupKeyString, - deviceID: deviceID, - userRecordName: userRecordName, + origin: origin, body ) } @@ -442,14 +405,12 @@ public final class UndoManager: Perceptible, @unchecked Sendable { @discardableResult public func withGroup( _ description: LocalizedStringResource, - deviceID: String? = nil, - userRecordName: String? = nil, + origin: UndoGroup.Origin = .local, _ body: @Sendable (Database) throws -> T ) async throws -> T { try await withGroup( String(localized: description), - deviceID: deviceID, - userRecordName: userRecordName, + origin: origin, body ) } @@ -470,14 +431,12 @@ public final class UndoManager: Perceptible, @unchecked Sendable { @discardableResult public func withGroup( _ description: String, - deviceID: String? = nil, - userRecordName: String? = nil, + origin: UndoGroup.Origin = .local, _ body: @Sendable (Database) throws -> T ) async throws -> T { let barrierID = try beginBarrier( description, - deviceID: deviceID, - userRecordName: userRecordName + origin: origin ) do { let result = try await database.write { db in @@ -491,56 +450,50 @@ public final class UndoManager: Perceptible, @unchecked Sendable { } } - /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + /// Synchronous variant of ``withGroup(_:origin:_:)``. #if canImport(SwiftUI) @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @_disfavoredOverload @discardableResult public func withGroup( _ description: LocalizedStringKey, - deviceID: String? = nil, - userRecordName: String? = nil, + origin: UndoGroup.Origin = .local, _ body: (Database) throws -> T ) throws -> T { try withGroup( description.undoGroupKeyString, - deviceID: deviceID, - userRecordName: userRecordName, + origin: origin, body ) } #endif - /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + /// Synchronous variant of ``withGroup(_:origin:_:)``. @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) @discardableResult public func withGroup( _ description: LocalizedStringResource, - deviceID: String? = nil, - userRecordName: String? = nil, + origin: UndoGroup.Origin = .local, _ body: (Database) throws -> T ) throws -> T { try withGroup( String(localized: description), - deviceID: deviceID, - userRecordName: userRecordName, + origin: origin, body ) } - /// Synchronous variant of ``withGroup(_:deviceID:userRecordName:_:)``. + /// Synchronous variant of ``withGroup(_:origin:_:)``. @_disfavoredOverload @discardableResult public func withGroup( _ description: String, - deviceID: String? = nil, - userRecordName: String? = nil, + origin: UndoGroup.Origin = .local, _ body: (Database) throws -> T ) throws -> T { let barrierID = try beginBarrier( description, - deviceID: deviceID, - userRecordName: userRecordName + origin: origin ) do { let result = try database.write { db in diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 7822cd3d..9a536d6b 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -138,23 +138,21 @@ extension DatabaseWriter where Self == DatabaseQueue { } // 5. Sync-origin writes can be grouped, undone, and carry synced-origin metadata. - @Test func syncIncludedWithMetadata() async throws { + @Test func syncIncludedWithOrigin() async throws { let db = try DatabaseQueue.undoDatabase() let undoManager = try UndoManager(for: db, tables: Item.self) try await $_isSynchronizingChanges.withValue(true) { try await undoManager.withGroup( "Sync insert", - deviceID: UndoManager.syncDeviceID, - userRecordName: "collaborator-user" + origin: .sync ) { db in _ = try Item.insert { Item.Draft(title: "Sync item") }.execute(db) } } #expect(undoManager.canUndo) - #expect(undoManager.undoStack.first?.deviceID == UndoManager.syncDeviceID) - #expect(undoManager.undoStack.first?.userRecordName == "collaborator-user") + #expect(undoManager.undoStack.first?.origin == .sync) try await undoManager.undo() let items = try await db.read { try Item.fetchAll($0) } @@ -451,7 +449,6 @@ extension DatabaseWriter where Self == DatabaseQueue { let undoManager = try UndoManager( for: db, tables: Item.self, - deviceID: "test-device", delegate: delegate ) @@ -462,7 +459,7 @@ extension DatabaseWriter where Self == DatabaseQueue { let group = await capture.capturedGroup #expect(group?.description == "My operation") - #expect(group?.deviceID == "test-device") + #expect(group?.origin == .local) } // 10. The delegate receives `.undo` for undo and `.redo` for redo. @@ -715,8 +712,7 @@ extension DatabaseWriter where Self == DatabaseQueue { } #expect(undoManager.undoStack.count == 1) - #expect(undoManager.undoStack.first?.deviceID == UndoManager.syncDeviceID) - #expect(undoManager.undoStack.first?.userRecordName == zoneID.ownerName) + #expect(undoManager.undoStack.first?.origin == .sync) try await undoManager.undo() let items = try await db.read { try Item.fetchAll($0) } From 78673458c5e278e640de1990c64e3962f763d009 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Fri, 27 Feb 2026 01:16:18 +0100 Subject: [PATCH 11/16] Revert SyncEngine undo-context wrappers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 72 +++++++------------- 1 file changed, 26 insertions(+), 46 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 1a9a49bb..871c7999 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1481,30 +1481,21 @@ if let table = tablesByName[recordType] { func open(_: some SynchronizableTable) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - let write = { - try await self.userDatabase.write { db in - try T - .where { - #sql("\($0.primaryKey)").in( - SyncMetadata.findAll(recordIDs) - .select(\.recordPrimaryKey) - ) - } - .delete() - .execute(db) + try await userDatabase.write { db in + try T + .where { + #sql("\($0.primaryKey)").in( + SyncMetadata.findAll(recordIDs) + .select(\.recordPrimaryKey) + ) + } + .delete() + .execute(db) - try UnsyncedRecordID - .findAll(recordIDs) - .delete() - .execute(db) - } - } - if let zoneID = recordIDs.first?.zoneID { - try await $_currentZoneID.withValue(zoneID) { - try await write() - } - } else { - try await write() + try UnsyncedRecordID + .findAll(recordIDs) + .delete() + .execute(db) } } } @@ -1594,28 +1585,19 @@ } let shares: [ShareOrReference] = await withErrorReporting(.sqliteDataCloudKitFailure) { - let write = { - try await self.userDatabase.write { db in - var shares: [ShareOrReference] = [] - for record in modifications { - if let share = record as? CKShare { - shares.append(.share(share)) - } else { - self.upsertFromServerRecord(record, db: db) - if let shareReference = record.share { - shares.append(.reference(shareReference)) - } + try await userDatabase.write { db in + var shares: [ShareOrReference] = [] + for record in modifications { + if let share = record as? CKShare { + shares.append(.share(share)) + } else { + upsertFromServerRecord(record, db: db) + if let shareReference = record.share { + shares.append(.reference(shareReference)) } } - return shares - } - } - if let zoneID = modifications.first?.recordID.zoneID { - return try await $_currentZoneID.withValue(zoneID) { - try await write() } - } else { - return try await write() + return shares } } ?? [] @@ -1910,10 +1892,8 @@ force: Bool = false ) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await $_currentZoneID.withValue(serverRecord.recordID.zoneID) { - try await userDatabase.write { db in - upsertFromServerRecord(serverRecord, force: force, db: db) - } + try await userDatabase.write { db in + upsertFromServerRecord(serverRecord, force: force, db: db) } } } From d7c69cbd8faec3f540ce193ef34290a8009540e2 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Fri, 27 Feb 2026 01:33:04 +0100 Subject: [PATCH 12/16] Extract undo jump APIs and split tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/Reminders/UndoToolbarButtons.swift | 49 +--- Sources/SQLiteData/Undo/UndoManager.swift | 44 +++ ...doManagerDelegateAndIntegrationTests.swift | 234 ++++++++++++++++ .../UndoTests/UndoManagerTests.swift | 260 +++--------------- 4 files changed, 326 insertions(+), 261 deletions(-) create mode 100644 Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift diff --git a/Examples/Reminders/UndoToolbarButtons.swift b/Examples/Reminders/UndoToolbarButtons.swift index 4ad5ee0f..2f3fe6f5 100644 --- a/Examples/Reminders/UndoToolbarButtons.swift +++ b/Examples/Reminders/UndoToolbarButtons.swift @@ -51,47 +51,26 @@ struct UndoMenuItems: View { } private func performUndo(to group: UndoGroup? = nil) { - perform(.undo, to: group) + guard let undoManager else { return } + Task { + await withErrorReporting { + if let group { + try await undoManager.undo(to: group) + } else { + try await undoManager.undo() + } + } + } } private func performRedo(to group: UndoGroup? = nil) { - perform(.redo, to: group) - } - - private func perform(_ action: UndoAction, to targetGroup: UndoGroup?) { guard let undoManager else { return } Task { await withErrorReporting { - let stack: [UndoGroup] - switch action { - case .undo: stack = undoManager.undoStack - case .redo: stack = undoManager.redoStack - } - let count = - targetGroup - .flatMap { target in stack.firstIndex { $0.id == target.id }.map { $0 + 1 } } - ?? 1 - guard count > 0 else { return } - for _ in 0.. 0 else { return } + + for _ in 0.. Void + ) async throws { + // Intentionally do NOT call performAction — cancel the undo. + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = CancelDelegate() + let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Persistent") }.execute(db) + } + #expect(undoManager.undoStack.count == 1) + + try await undoManager.undo() + + // Stack unchanged; row still present. + #expect(undoManager.undoStack.count == 1) + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + + // 9. The delegate receives metadata matching what was passed to withGroup. + @Test func delegateReceivesMetadata() async throws { + actor MetadataCapture { + var capturedGroup: UndoGroup? + func capture(_ group: UndoGroup) { capturedGroup = group } + } + let capture = MetadataCapture() + + final class MetadataDelegate: UndoManagerDelegate, @unchecked Sendable { + let capture: MetadataCapture + init(_ capture: MetadataCapture) { self.capture = capture } + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + await capture.capture(group) + try await performAction() + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = MetadataDelegate(capture) + let undoManager = try UndoManager( + for: db, + tables: Item.self, + delegate: delegate + ) + + try await undoManager.withGroup("My operation") { db in + _ = try Item.insert { Item.Draft(title: "Hi") }.execute(db) + } + try await undoManager.undo() + + let group = await capture.capturedGroup + #expect(group?.description == "My operation") + #expect(group?.origin == .local) + } + + // 10. The delegate receives `.undo` for undo and `.redo` for redo. + @Test func delegateActionType() async throws { + actor ActionCapture { + var actions: [UndoAction] = [] + func append(_ action: UndoAction) { actions.append(action) } + } + let capture = ActionCapture() + + final class ActionDelegate: UndoManagerDelegate, @unchecked Sendable { + let capture: ActionCapture + init(_ capture: ActionCapture) { self.capture = capture } + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + await capture.append(action) + try await performAction() + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = ActionDelegate(capture) + let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) + + try await undoManager.withGroup("Insert") { db in + _ = try Item.insert { Item.Draft(title: "Z") }.execute(db) + } + try await undoManager.undo() + try await undoManager.redo() + + let actions = await capture.actions + #expect(actions == [.undo, .redo]) + } + + #if canImport(ObjectiveC) + @Test func foundationUndoBridgeRoundTrip() async throws { + let db = try DatabaseQueue.undoDatabase() + let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) + let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } + sqliteUndoManager.bind(to: foundationUndoManager) + + try await sqliteUndoManager.withGroup("Insert via bridge") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + + try await waitUntil { + await MainActor.run { foundationUndoManager.canUndo } + } + #expect(await MainActor.run { foundationUndoManager.canUndo }) + + await MainActor.run { foundationUndoManager.undo() } + + try await waitUntil { + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + return count == 0 && sqliteUndoManager.canRedo + } + + let countAfterUndo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(countAfterUndo == 0) + #expect(sqliteUndoManager.canRedo) + #expect(await MainActor.run { foundationUndoManager.canRedo }) + + await MainActor.run { foundationUndoManager.redo() } + + try await waitUntil { + let count = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + return count == 1 && sqliteUndoManager.canUndo + } + + let countAfterRedo = try await db.read { db in + try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 + } + #expect(countAfterRedo == 1) + #expect(sqliteUndoManager.canUndo) + } + + @Test func foundationUndoBridgeUnboundFallback() async throws { + let db = try DatabaseQueue.undoDatabase() + let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) + let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } + + try await sqliteUndoManager.withGroup("Standalone insert") { db in + _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) + } + + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(sqliteUndoManager.canUndo) + #expect(!(await MainActor.run { foundationUndoManager.canUndo })) + } + + private func waitUntil( + _ condition: @escaping @Sendable () async throws -> Bool + ) async throws { + for _ in 0..<200 { + if try await condition() { + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(Bool(false)) + } + #endif + + #if canImport(CloudKit) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncEngineWriteWrappedByUserDatabaseIsUndoable() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) + } + } + + #expect(undoManager.undoStack.count == 1) + #expect(undoManager.undoStack.first?.origin == .sync) + + try await undoManager.undo() + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.isEmpty) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncEngineWriteWithoutUndoManagerStillWorks() async throws { + try await withDependencies { + $0.defaultUndoManager = nil + } operation: { + let db = try DatabaseQueue.undoDatabase() + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) + } + } + + let items = try await db.read { try Item.fetchAll($0) } + #expect(items.count == 1) + } + } + #endif +} diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 9a536d6b..5cfc428c 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -8,7 +8,7 @@ import Testing // MARK: - Schema -@Table private struct Item: Equatable, Identifiable { +@Table struct Item: Equatable, Identifiable { let id: Int var title: String } @@ -32,7 +32,7 @@ import Testing // MARK: - Database helpers extension DatabaseWriter where Self == DatabaseQueue { - fileprivate static func undoDatabase() throws -> DatabaseQueue { + static func undoDatabase() throws -> DatabaseQueue { let database = try DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create items") { db in @@ -53,7 +53,7 @@ extension DatabaseWriter where Self == DatabaseQueue { // MARK: - Tests -@Suite struct UndoManagerTests { +@Suite struct UndoManagerCoreTests { @Test func defaultUndoManagerDependencyDefaultsToNil() { @Dependency(\.defaultUndoManager) var defaultUndoManager #expect(defaultUndoManager == nil) @@ -137,6 +137,37 @@ extension DatabaseWriter where Self == DatabaseQueue { #expect(undoManager.undoStack.count == 1) } + @Test func undoRedoToSpecificGroup() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await undoManager.withGroup("Insert B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + try await undoManager.withGroup("Insert C") { db in + _ = try Item.insert { Item.Draft(title: "C") }.execute(db) + } + + let undoTarget = try #require(undoManager.undoStack.dropFirst().first) + try await undoManager.undo(to: undoTarget) + + let titlesAfterUndoTo = try await db.read { db in + try String.fetchAll(db, sql: "SELECT title FROM items ORDER BY id") + } + #expect(titlesAfterUndoTo == ["A"]) + + let redoTarget = try #require(undoManager.redoStack.dropFirst().first) + try await undoManager.redo(to: redoTarget) + + let titlesAfterRedoTo = try await db.read { db in + try String.fetchAll(db, sql: "SELECT title FROM items ORDER BY id") + } + #expect(titlesAfterRedoTo == ["A", "B", "C"]) + } + // 5. Sync-origin writes can be grouped, undone, and carry synced-origin metadata. @Test func syncIncludedWithOrigin() async throws { let db = try DatabaseQueue.undoDatabase() @@ -393,111 +424,6 @@ extension DatabaseWriter where Self == DatabaseQueue { } } - @Test func delegateCancel() async throws { - final class CancelDelegate: UndoManagerDelegate { - func undoManager( - _ undoManager: SQLiteData.UndoManager, - willPerform action: UndoAction, - for group: UndoGroup, - performAction: @Sendable () async throws -> Void - ) async throws { - // Intentionally do NOT call performAction — cancel the undo. - } - } - - let db = try DatabaseQueue.undoDatabase() - let delegate = CancelDelegate() - let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) - - try await undoManager.withGroup("Insert") { db in - _ = try Item.insert { Item.Draft(title: "Persistent") }.execute(db) - } - #expect(undoManager.undoStack.count == 1) - - try await undoManager.undo() - - // Stack unchanged; row still present. - #expect(undoManager.undoStack.count == 1) - let items = try await db.read { try Item.fetchAll($0) } - #expect(items.count == 1) - } - - // 9. The delegate receives metadata matching what was passed to withGroup. - @Test func delegateReceivesMetadata() async throws { - actor MetadataCapture { - var capturedGroup: UndoGroup? - func capture(_ group: UndoGroup) { capturedGroup = group } - } - let capture = MetadataCapture() - - final class MetadataDelegate: UndoManagerDelegate, @unchecked Sendable { - let capture: MetadataCapture - init(_ capture: MetadataCapture) { self.capture = capture } - func undoManager( - _ undoManager: SQLiteData.UndoManager, - willPerform action: UndoAction, - for group: UndoGroup, - performAction: @Sendable () async throws -> Void - ) async throws { - await capture.capture(group) - try await performAction() - } - } - - let db = try DatabaseQueue.undoDatabase() - let delegate = MetadataDelegate(capture) - let undoManager = try UndoManager( - for: db, - tables: Item.self, - delegate: delegate - ) - - try await undoManager.withGroup("My operation") { db in - _ = try Item.insert { Item.Draft(title: "Hi") }.execute(db) - } - try await undoManager.undo() - - let group = await capture.capturedGroup - #expect(group?.description == "My operation") - #expect(group?.origin == .local) - } - - // 10. The delegate receives `.undo` for undo and `.redo` for redo. - @Test func delegateActionType() async throws { - actor ActionCapture { - var actions: [UndoAction] = [] - func append(_ action: UndoAction) { actions.append(action) } - } - let capture = ActionCapture() - - final class ActionDelegate: UndoManagerDelegate, @unchecked Sendable { - let capture: ActionCapture - init(_ capture: ActionCapture) { self.capture = capture } - func undoManager( - _ undoManager: SQLiteData.UndoManager, - willPerform action: UndoAction, - for group: UndoGroup, - performAction: @Sendable () async throws -> Void - ) async throws { - await capture.append(action) - try await performAction() - } - } - - let db = try DatabaseQueue.undoDatabase() - let delegate = ActionDelegate(capture) - let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) - - try await undoManager.withGroup("Insert") { db in - _ = try Item.insert { Item.Draft(title: "Z") }.execute(db) - } - try await undoManager.undo() - try await undoManager.redo() - - let actions = await capture.actions - #expect(actions == [.undo, .redo]) - } - // 11. The description from withGroup appears in undoStack. @Test func undoDescriptionRoundtrip() async throws { let db = try DatabaseQueue.undoDatabase() @@ -621,122 +547,4 @@ extension DatabaseWriter where Self == DatabaseQueue { #expect(countAfterRedo == 0) } - #if canImport(ObjectiveC) - @Test func foundationUndoBridgeRoundTrip() async throws { - let db = try DatabaseQueue.undoDatabase() - let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) - let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } - sqliteUndoManager.bind(to: foundationUndoManager) - - try await sqliteUndoManager.withGroup("Insert via bridge") { db in - _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) - } - - try await waitUntil { - await MainActor.run { foundationUndoManager.canUndo } - } - #expect(await MainActor.run { foundationUndoManager.canUndo }) - - await MainActor.run { foundationUndoManager.undo() } - - try await waitUntil { - let count = try await db.read { db in - try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 - } - return count == 0 && sqliteUndoManager.canRedo - } - - let countAfterUndo = try await db.read { db in - try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 - } - #expect(countAfterUndo == 0) - #expect(sqliteUndoManager.canRedo) - #expect(await MainActor.run { foundationUndoManager.canRedo }) - - await MainActor.run { foundationUndoManager.redo() } - - try await waitUntil { - let count = try await db.read { db in - try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 - } - return count == 1 && sqliteUndoManager.canUndo - } - - let countAfterRedo = try await db.read { db in - try Int.fetchOne(db, sql: #"SELECT COUNT(*) FROM "items""#) ?? 0 - } - #expect(countAfterRedo == 1) - #expect(sqliteUndoManager.canUndo) - } - - @Test func foundationUndoBridgeUnboundFallback() async throws { - let db = try DatabaseQueue.undoDatabase() - let sqliteUndoManager = try SQLiteUndoManager(for: db, tables: Item.self) - let foundationUndoManager = await MainActor.run { Foundation.UndoManager() } - - try await sqliteUndoManager.withGroup("Standalone insert") { db in - _ = try Item.insert { Item.Draft(title: "Hello") }.execute(db) - } - - try await Task.sleep(nanoseconds: 50_000_000) - - #expect(sqliteUndoManager.canUndo) - #expect(!(await MainActor.run { foundationUndoManager.canUndo })) - } - - private func waitUntil( - _ condition: @escaping @Sendable () async throws -> Bool - ) async throws { - for _ in 0..<200 { - if try await condition() { - return - } - try await Task.sleep(nanoseconds: 10_000_000) - } - #expect(Bool(false)) - } - #endif - - #if canImport(CloudKit) - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func syncEngineWriteWrappedByUserDatabaseIsUndoable() async throws { - let db = try DatabaseQueue.undoDatabase() - let undoManager = try UndoManager(for: db, tables: Item.self) - let userDatabase = UserDatabase(database: db) - let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") - - try await $_currentZoneID.withValue(zoneID) { - try await userDatabase.write { db in - _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) - } - } - - #expect(undoManager.undoStack.count == 1) - #expect(undoManager.undoStack.first?.origin == .sync) - - try await undoManager.undo() - let items = try await db.read { try Item.fetchAll($0) } - #expect(items.isEmpty) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func syncEngineWriteWithoutUndoManagerStillWorks() async throws { - try await withDependencies { - $0.defaultUndoManager = nil - } operation: { - let db = try DatabaseQueue.undoDatabase() - let userDatabase = UserDatabase(database: db) - let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") - - try await $_currentZoneID.withValue(zoneID) { - try await userDatabase.write { db in - _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) - } - } - - let items = try await db.read { try Item.fetchAll($0) } - #expect(items.count == 1) - } - } - #endif } From d9501ed62db42cedbd46cc2c9dc764dc83385c5b Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Fri, 27 Feb 2026 01:43:51 +0100 Subject: [PATCH 13/16] Address undo review followups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 21 ++++++++ .../CloudKit/Internal/UserDatabase.swift | 8 +-- .../SQLiteData/Undo/DatabaseWriter+Undo.swift | 24 +++------ Sources/SQLiteData/Undo/UndoManager.swift | 12 +++++ ...doManagerDelegateAndIntegrationTests.swift | 54 +++++++++++++++++++ .../UndoTests/UndoManagerTests.swift | 28 ++++++++++ 6 files changed, 123 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 66c5fd6d..54e048e8 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,27 @@ struct MyApp: App { > For more information on synchronizing the database to CloudKit and sharing records with iCloud > users, see [CloudKit Synchronization]. +### Undo support + +SQLiteData also supports undo/redo groups: + +```swift +let undoManager = try UndoManager(for: database, tables: Item.self) + +try await undoManager.withGroup("Add items", origin: .local) { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) +} + +// Undo a specific group from the stack. +if let group = undoManager.undoStack.dropFirst().first { + try await undoManager.undo(to: group) +} +``` + +Each `UndoGroup` records an `origin` (`.local` or `.sync`) so UIs can distinguish local edits +from synced changes. + This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles][articles] below to learn how to best utilize this library: diff --git a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift index 43f7162e..fcf90ae6 100644 --- a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift @@ -19,9 +19,7 @@ _ updates: @Sendable (Database) throws -> T ) async throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: database) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: database) + let undoManager = UndoManager.manager(for: database, defaultUndoManager: defaultUndoManager) if let undoManager { return try await undoManager.withGroup( "Sync iCloud changes", @@ -52,9 +50,7 @@ _ updates: (Database) throws -> T ) throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: database) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: database) + let undoManager = UndoManager.manager(for: database, defaultUndoManager: defaultUndoManager) if let undoManager { return try undoManager.withGroup( "Sync iCloud changes", diff --git a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift index 0db10bfa..dd59e9d9 100644 --- a/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift +++ b/Sources/SQLiteData/Undo/DatabaseWriter+Undo.swift @@ -11,9 +11,7 @@ public extension DatabaseWriter { _ updates: (Database) throws -> T ) throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: self) + let undoManager = UndoManager.manager(for: self, defaultUndoManager: defaultUndoManager) if let undoManager { return try undoManager.withGroup(description, updates) } @@ -28,9 +26,7 @@ public extension DatabaseWriter { _ updates: (Database) throws -> T ) throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: self) + let undoManager = UndoManager.manager(for: self, defaultUndoManager: defaultUndoManager) if let undoManager { return try undoManager.withGroup(description, updates) } @@ -43,9 +39,7 @@ public extension DatabaseWriter { _ updates: (Database) throws -> T ) throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: self) + let undoManager = UndoManager.manager(for: self, defaultUndoManager: defaultUndoManager) if let undoManager { return try undoManager.withGroup(description, updates) } @@ -60,9 +54,7 @@ public extension DatabaseWriter { _ updates: @Sendable (Database) throws -> T ) async throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: self) + let undoManager = UndoManager.manager(for: self, defaultUndoManager: defaultUndoManager) if let undoManager { return try await undoManager.withGroup(description, updates) } @@ -76,9 +68,7 @@ public extension DatabaseWriter { _ updates: @Sendable (Database) throws -> T ) async throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: self) + let undoManager = UndoManager.manager(for: self, defaultUndoManager: defaultUndoManager) if let undoManager { return try await undoManager.withGroup(description, updates) } @@ -91,9 +81,7 @@ public extension DatabaseWriter { _ updates: @Sendable (Database) throws -> T ) async throws -> T { @Dependency(\.defaultUndoManager) var defaultUndoManager - let undoManager = - (defaultUndoManager?.manages(database: self) == true ? defaultUndoManager : nil) - ?? UndoManager.manager(for: self) + let undoManager = UndoManager.manager(for: self, defaultUndoManager: defaultUndoManager) if let undoManager { return try await undoManager.withGroup(description, updates) } diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index de876568..fdfb43d0 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -179,6 +179,14 @@ public final class UndoManager: Perceptible, @unchecked Sendable { } } + package static func manager( + for database: any DatabaseWriter, + defaultUndoManager: UndoManager? + ) -> UndoManager? { + (defaultUndoManager?.manages(database: database) == true ? defaultUndoManager : nil) + ?? manager(for: database) + } + package func manages(database: any DatabaseWriter) -> Bool { databaseID == ObjectIdentifier(database as AnyObject) } @@ -518,6 +526,8 @@ public final class UndoManager: Perceptible, @unchecked Sendable { } /// Reverts changes up to and including a specific undo group. + /// + /// The delegate is consulted for each individual step. If a step is cancelled, processing stops. public func undo(to group: UndoGroup) async throws { try await perform(.undo, to: group) } @@ -531,6 +541,8 @@ public final class UndoManager: Perceptible, @unchecked Sendable { } /// Re-applies changes up to and including a specific redo group. + /// + /// The delegate is consulted for each individual step. If a step is cancelled, processing stops. public func redo(to group: UndoGroup) async throws { try await perform(.redo, to: group) } diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift index f9911d02..f414e6ef 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift @@ -113,6 +113,60 @@ import Testing #expect(actions == [.undo, .redo]) } + @Test func undoToStopsWhenDelegateCancelsMidJump() async throws { + actor CallCounter { + var count = 0 + func increment() -> Int { + count += 1 + return count + } + func value() -> Int { count } + } + let counter = CallCounter() + + final class CancelOnSecondCallDelegate: UndoManagerDelegate, @unchecked Sendable { + let counter: CallCounter + init(counter: CallCounter) { + self.counter = counter + } + + func undoManager( + _ undoManager: SQLiteData.UndoManager, + willPerform action: UndoAction, + for group: UndoGroup, + performAction: @Sendable () async throws -> Void + ) async throws { + let call = await counter.increment() + if call == 1 { + try await performAction() + } + } + } + + let db = try DatabaseQueue.undoDatabase() + let delegate = CancelOnSecondCallDelegate(counter: counter) + let undoManager = try UndoManager(for: db, tables: Item.self, delegate: delegate) + + try await undoManager.withGroup("Insert A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await undoManager.withGroup("Insert B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + try await undoManager.withGroup("Insert C") { db in + _ = try Item.insert { Item.Draft(title: "C") }.execute(db) + } + + let target = try #require(undoManager.undoStack.dropFirst().first) + try await undoManager.undo(to: target) + + let titles = try await db.read { db in + try String.fetchAll(db, sql: "SELECT title FROM items ORDER BY id") + } + #expect(titles == ["A", "B"]) + #expect(await counter.value() == 2) + } + #if canImport(ObjectiveC) @Test func foundationUndoBridgeRoundTrip() async throws { let db = try DatabaseQueue.undoDatabase() diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 5cfc428c..8489b176 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -168,6 +168,34 @@ extension DatabaseWriter where Self == DatabaseQueue { #expect(titlesAfterRedoTo == ["A", "B", "C"]) } + @Test func undoRedoToMissingGroupNoops() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager(for: db, tables: Item.self) + + try await undoManager.withGroup("Insert A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await undoManager.withGroup("Insert B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + + let target = try #require(undoManager.undoStack.first) + try await undoManager.undo(to: target) + + let undoIDsBeforeMissingUndo = undoManager.undoStack.map(\.id) + let redoIDsBeforeMissingUndo = undoManager.redoStack.map(\.id) + try await undoManager.undo(to: target) + #expect(undoManager.undoStack.map(\.id) == undoIDsBeforeMissingUndo) + #expect(undoManager.redoStack.map(\.id) == redoIDsBeforeMissingUndo) + + try await undoManager.redo(to: target) + let undoIDsBeforeMissingRedo = undoManager.undoStack.map(\.id) + let redoIDsBeforeMissingRedo = undoManager.redoStack.map(\.id) + try await undoManager.redo(to: target) + #expect(undoManager.undoStack.map(\.id) == undoIDsBeforeMissingRedo) + #expect(undoManager.redoStack.map(\.id) == redoIDsBeforeMissingRedo) + } + // 5. Sync-origin writes can be grouped, undone, and carry synced-origin metadata. @Test func syncIncludedWithOrigin() async throws { let db = try DatabaseQueue.undoDatabase() From 90a73009f37273f39e8af2a8816ef59f0ca71ad8 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Fri, 27 Feb 2026 09:18:04 +0100 Subject: [PATCH 14/16] Add configurable sync undo policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 + .../CloudKit/Internal/UserDatabase.swift | 10 +- Sources/SQLiteData/Undo/UndoManager.swift | 186 +++++++++++++++++- ...doManagerDelegateAndIntegrationTests.swift | 101 +++++++++- .../UndoTests/UndoManagerTests.swift | 4 +- 5 files changed, 300 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 54e048e8..55fca51d 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,16 @@ if let group = undoManager.undoStack.dropFirst().first { Each `UndoGroup` records an `origin` (`.local` or `.sync`) so UIs can distinguish local edits from synced changes. +You can also control how synced writes interact with undo history: + +```swift +let undoManager = try UndoManager( + for: database, + tables: Item.self, + syncUndoPolicy: .disabled(boundary: .stopAtBoundary) +) +``` + This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles][articles] below to learn how to best utilize this library: diff --git a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift index fcf90ae6..5b50ed08 100644 --- a/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/UserDatabase.swift @@ -21,10 +21,7 @@ @Dependency(\.defaultUndoManager) var defaultUndoManager let undoManager = UndoManager.manager(for: database, defaultUndoManager: defaultUndoManager) if let undoManager { - return try await undoManager.withGroup( - "Sync iCloud changes", - origin: .sync - ) { db in + return try await undoManager.writeSyncChanges { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) } @@ -52,10 +49,7 @@ @Dependency(\.defaultUndoManager) var defaultUndoManager let undoManager = UndoManager.manager(for: database, defaultUndoManager: defaultUndoManager) if let undoManager { - return try undoManager.withGroup( - "Sync iCloud changes", - origin: .sync - ) { db in + return try undoManager.writeSyncChanges { db in try $_isSynchronizingChanges.withValue(true) { try updates(db) } diff --git a/Sources/SQLiteData/Undo/UndoManager.swift b/Sources/SQLiteData/Undo/UndoManager.swift index fdfb43d0..4916e351 100644 --- a/Sources/SQLiteData/Undo/UndoManager.swift +++ b/Sources/SQLiteData/Undo/UndoManager.swift @@ -1,4 +1,5 @@ import ConcurrencyExtras +import Dependencies import Foundation import GRDB import IssueReporting @@ -71,10 +72,46 @@ public final class UndoManager: Perceptible, @unchecked Sendable { case notFound } + /// Summary information for a recorded sync undo group. + public struct SyncUndoSummary: Sendable { + /// Table names touched by the recorded sync group. + public let affectedTables: Set + /// Number of inverse log entries captured for the sync group. + public let changeCount: Int + + public init(affectedTables: Set, changeCount: Int) { + self.affectedTables = affectedTables + self.changeCount = changeCount + } + } + + /// Behavior for history around sync changes when sync undo registration is disabled. + public enum SyncBoundaryBehavior: Sendable { + /// Keep existing local undo history, allowing undo operations to cross past sync changes. + case allowCrossing + /// Prevent undo from crossing sync changes by clearing undo/redo history at each sync write. + case stopAtBoundary + } + + /// Policy controlling how sync-applied writes interact with undo history. + public enum SyncUndoPolicy: Sendable { + /// Record sync changes as undo groups. + /// + /// The `actionName` closure customizes each sync undo group's description. + case enabled( + actionName: @Sendable (_ summary: SyncUndoSummary) -> String = { _ in "Sync iCloud changes" } + ) + /// Do not register sync changes as undo groups. + /// + /// Use `boundary` to control whether existing local history can be undone past sync writes. + case disabled(boundary: SyncBoundaryBehavior = .stopAtBoundary) + } + private let _state = LockIsolated(State()) private let database: any DatabaseWriter private let databaseID: ObjectIdentifier private let trackedTableNames: Set + private let syncUndoPolicy: SyncUndoPolicy private let delegate: (any UndoManagerDelegate)? private let eventsContinuation: AsyncStream.Continuation public let events: AsyncStream @@ -128,12 +165,15 @@ public final class UndoManager: Perceptible, @unchecked Sendable { /// - Parameters: /// - database: The database to observe. /// - tables: The names of the tables whose changes should be undoable. + /// - syncUndoPolicy: Controls whether sync changes are recorded as undo groups and whether + /// undo history can cross sync boundaries when not recorded. /// - delegate: An optional delegate that can intercept and confirm undo/redo operations. public init< each T: PrimaryKeyedTable & _SendableMetatype >( for database: any DatabaseWriter, tables: repeat (each T).Type, + syncUndoPolicy: SyncUndoPolicy = .enabled(), delegate: (any UndoManagerDelegate)? = nil ) throws { var trackedTableNames = Set() @@ -143,6 +183,7 @@ public final class UndoManager: Perceptible, @unchecked Sendable { (self.events, self.eventsContinuation) = AsyncStream.makeStream() self.database = database self.databaseID = ObjectIdentifier(database as AnyObject) + self.syncUndoPolicy = syncUndoPolicy self.delegate = delegate self.trackedTableNames = trackedTableNames @@ -254,10 +295,11 @@ public final class UndoManager: Perceptible, @unchecked Sendable { _ description: String, origin: UndoGroup.Origin = .local ) throws -> UUID { + @Dependency(\.date.now) var now let group = UndoGroup( description: description, origin: origin, - date: Date() + date: now ) let barrierID = UUID() try _state.withValue { state in @@ -547,6 +589,45 @@ public final class UndoManager: Perceptible, @unchecked Sendable { try await perform(.redo, to: group) } + package func writeSyncChanges( + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + switch syncUndoPolicy { + case .enabled(let actionName): + return try await recordSyncChanges(actionName: actionName, updates) + case .disabled(let boundary): + let result = try await $_isUndoRecordingDisabled.withValue(true) { + try await database.write { db in + try updates(db) + } + } + if boundary == .stopAtBoundary { + dropHistoryAtSyncBoundary() + } + return result + } + } + + @_disfavoredOverload + package func writeSyncChanges( + _ updates: (Database) throws -> T + ) throws -> T { + switch syncUndoPolicy { + case .enabled(let actionName): + return try recordSyncChanges(actionName: actionName, updates) + case .disabled(let boundary): + let result = try $_isUndoRecordingDisabled.withValue(true) { + try database.write { db in + try updates(db) + } + } + if boundary == .stopAtBoundary { + dropHistoryAtSyncBoundary() + } + return result + } + } + // MARK: - Freeze / Unfreeze /// Suspends undo recording. @@ -587,6 +668,109 @@ public final class UndoManager: Perceptible, @unchecked Sendable { // MARK: - Private helpers + private func recordSyncChanges( + actionName: @Sendable (SyncUndoSummary) -> String, + _ updates: @Sendable (Database) throws -> T + ) async throws -> T { + @Dependency(\.date.now) var now + let firstLog = _state.value.firstLog + let result = try await database.write { db in + try updates(db) + } + guard let summary = try await syncUndoSummary(from: firstLog) else { + return result + } + let group = UndoGroup( + description: actionName( + SyncUndoSummary(affectedTables: summary.modifiedTables, changeCount: summary.changeCount) + ), + origin: .sync, + date: now + ) + _ = finalizeBarrier( + OpenBarrier(group: group, firstLog: firstLog), + maxSeq: summary.maxSeq, + modifiedTables: summary.modifiedTables + ) + return result + } + + private func recordSyncChanges( + actionName: @Sendable (SyncUndoSummary) -> String, + _ updates: (Database) throws -> T + ) throws -> T { + @Dependency(\.date.now) var now + let firstLog = _state.value.firstLog + let result = try database.write { db in + try updates(db) + } + guard let summary = try syncUndoSummary(from: firstLog) else { + return result + } + let group = UndoGroup( + description: actionName( + SyncUndoSummary(affectedTables: summary.modifiedTables, changeCount: summary.changeCount) + ), + origin: .sync, + date: now + ) + _ = finalizeBarrier( + OpenBarrier(group: group, firstLog: firstLog), + maxSeq: summary.maxSeq, + modifiedTables: summary.modifiedTables + ) + return result + } + + private func syncUndoSummary(from firstLog: Int) async throws -> ( + maxSeq: Int, modifiedTables: Set, changeCount: Int + )? { + try await database.write { db in + try syncUndoSummary(in: db, from: firstLog) + } + } + + private func syncUndoSummary(from firstLog: Int) throws -> ( + maxSeq: Int, modifiedTables: Set, changeCount: Int + )? { + try database.write { db in + try syncUndoSummary(in: db, from: firstLog) + } + } + + private func syncUndoSummary( + in db: Database, + from firstLog: Int + ) throws -> (maxSeq: Int, modifiedTables: Set, changeCount: Int)? { + guard var maxSeq = try UndoLog.order(by: { $0.seq.desc() }).fetchOne(db)?.seq, maxSeq >= firstLog + else { + return nil + } + try undoReconcileEntries(in: db, from: firstLog, to: maxSeq) + maxSeq = try UndoLog.order { $0.seq.desc() }.fetchOne(db)?.seq ?? 0 + guard maxSeq >= firstLog else { return nil } + let rows = try UndoLog + .where { $0.seq >= firstLog && $0.seq <= maxSeq } + .fetchAll(db) + guard !rows.isEmpty else { return nil } + return ( + maxSeq, + Set(rows.map(\.tableName)), + rows.count + ) + } + + private func dropHistoryAtSyncBoundary() { + _$perceptionRegistrar.withMutation(of: self, keyPath: \.undoStack) { + _$perceptionRegistrar.withMutation(of: self, keyPath: \.redoStack) { + _state.withValue { state in + state.undoEntries = [] + state.redoEntries = [] + } + } + } + } + private func finalizeBarrier( _ barrier: OpenBarrier, maxSeq: Int, diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift index f414e6ef..81bec26c 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerDelegateAndIntegrationTests.swift @@ -1,12 +1,14 @@ import Foundation import Dependencies +import DependenciesTestSupport import SQLiteData import Testing #if canImport(CloudKit) import CloudKit #endif -@Suite struct UndoManagerDelegateAndIntegrationTests { +@Suite(.dependencies { $0.date.now = Date(timeIntervalSince1970: 0) }) +struct UndoManagerDelegateAndIntegrationTests { @Test func delegateCancel() async throws { final class CancelDelegate: UndoManagerDelegate { @@ -284,5 +286,102 @@ import Testing #expect(items.count == 1) } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncUndoPolicyCustomActionName() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager( + for: db, + tables: Item.self, + syncUndoPolicy: .enabled( + actionName: { summary in + "Synced \(summary.changeCount) changes across \(summary.affectedTables.count) table(s)" + } + ) + ) + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Synced item") }.execute(db) + } + } + + #expect(undoManager.undoStack.count == 1) + #expect(undoManager.undoStack.first?.origin == .sync) + #expect(undoManager.undoStack.first?.description == "Synced 1 changes across 1 table(s)") + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncUndoPolicyDisabledAllowCrossing() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager( + for: db, + tables: Item.self, + syncUndoPolicy: .disabled(boundary: .allowCrossing) + ) + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await undoManager.withGroup("Local A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Sync") }.execute(db) + } + } + try await undoManager.withGroup("Local B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + + #expect(undoManager.undoStack.map(\.description) == ["Local B", "Local A"]) + #expect(undoManager.undoStack.allSatisfy { $0.origin == .local }) + + try await undoManager.undo() + try await undoManager.undo() + + let titles = try await db.read { db in + try String.fetchAll(db, sql: "SELECT title FROM items ORDER BY id") + } + #expect(titles == ["Sync"]) + #expect(undoManager.undoStack.isEmpty) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncUndoPolicyDisabledStopAtBoundary() async throws { + let db = try DatabaseQueue.undoDatabase() + let undoManager = try UndoManager( + for: db, + tables: Item.self, + syncUndoPolicy: .disabled(boundary: .stopAtBoundary) + ) + let userDatabase = UserDatabase(database: db) + let zoneID = CKRecordZone.ID(zoneName: "shared-zone", ownerName: "collaborator-user") + + try await undoManager.withGroup("Local A") { db in + _ = try Item.insert { Item.Draft(title: "A") }.execute(db) + } + try await $_currentZoneID.withValue(zoneID) { + try await userDatabase.write { db in + _ = try Item.insert { Item.Draft(title: "Sync") }.execute(db) + } + } + try await undoManager.withGroup("Local B") { db in + _ = try Item.insert { Item.Draft(title: "B") }.execute(db) + } + + #expect(undoManager.undoStack.map(\.description) == ["Local B"]) + + try await undoManager.undo() + try await undoManager.undo() + + let titles = try await db.read { db in + try String.fetchAll(db, sql: "SELECT title FROM items ORDER BY id") + } + #expect(titles == ["A", "Sync"]) + #expect(undoManager.undoStack.isEmpty) + } #endif } diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 8489b176..2235bbd9 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -1,5 +1,6 @@ import Foundation import Dependencies +import DependenciesTestSupport import SQLiteData import Testing #if canImport(CloudKit) @@ -53,7 +54,8 @@ extension DatabaseWriter where Self == DatabaseQueue { // MARK: - Tests -@Suite struct UndoManagerCoreTests { +@Suite(.dependencies { $0.date.now = Date(timeIntervalSince1970: 0) }) +struct UndoManagerCoreTests { @Test func defaultUndoManagerDependencyDefaultsToNil() { @Dependency(\.defaultUndoManager) var defaultUndoManager #expect(defaultUndoManager == nil) From 6e28a747583b16597a28211d5bb62ad799ea49c9 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Fri, 27 Feb 2026 09:38:58 +0100 Subject: [PATCH 15/16] Move undo suppression to DatabaseWriter API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/Reminders/RemindersLists.swift | 13 +---- README.md | 10 ++++ Sources/SQLiteData/Undo/WithoutUndo.swift | 48 +++++++++++-------- .../UndoTests/UndoManagerTests.swift | 6 +-- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 6181d8c4..8fb11f1d 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -149,17 +149,8 @@ class RemindersListsModel { #if DEBUG func seedDatabaseButtonTapped() { Task { - await withErrorReporting { - if let undoManager { - try await undoManager.freeze() - do { - try database.seedSampleData() - try await undoManager.unfreeze() - } catch { - try await undoManager.unfreeze() - throw error - } - } else { + withErrorReporting { + try database.writeWithoutUndoGroup { try database.seedSampleData() } } diff --git a/README.md b/README.md index 55fca51d..3fa3346c 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,16 @@ let undoManager = try UndoManager( ) ``` +When you need writes that should not participate in undo logging at all, use: + +```swift +try await database.writeWithoutUndoGroup { + try await database.write { db in + // Seed or maintenance writes + } +} +``` + This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles][articles] below to learn how to best utilize this library: diff --git a/Sources/SQLiteData/Undo/WithoutUndo.swift b/Sources/SQLiteData/Undo/WithoutUndo.swift index 55cd718c..007da719 100644 --- a/Sources/SQLiteData/Undo/WithoutUndo.swift +++ b/Sources/SQLiteData/Undo/WithoutUndo.swift @@ -1,23 +1,33 @@ -/// Executes work while undo trigger recording is disabled. -/// -/// Use this to perform writes that should not become undoable entries. -@discardableResult -public func withoutUndo( - _ operation: () throws -> T -) rethrows -> T { - try $_isUndoRecordingDisabled.withValue(true) { - try operation() +public extension DatabaseWriter { + /// Executes work while undo trigger recording is disabled. + /// + /// Use this to perform writes that should not become undoable entries. + /// + /// This differs from using `write { ... }` directly instead of + /// `writeWithUndoGroup(...)`: a plain `write` still allows undo triggers to record inverse SQL + /// in the undo log table, while this API suppresses trigger recording entirely. + @discardableResult + func writeWithoutUndoGroup( + _ operation: () throws -> T + ) rethrows -> T { + try $_isUndoRecordingDisabled.withValue(true) { + try operation() + } } -} -/// Executes async work while undo trigger recording is disabled. -/// -/// Use this to perform writes that should not become undoable entries. -@discardableResult -public func withoutUndo( - _ operation: @Sendable () async throws -> T -) async rethrows -> T { - try await $_isUndoRecordingDisabled.withValue(true) { - try await operation() + /// Executes async work while undo trigger recording is disabled. + /// + /// Use this to perform writes that should not become undoable entries. + /// + /// This differs from using `write { ... }` directly instead of + /// `writeWithUndoGroup(...)`: a plain `write` still allows undo triggers to record inverse SQL + /// in the undo log table, while this API suppresses trigger recording entirely. + @discardableResult + func writeWithoutUndoGroup( + _ operation: @Sendable () async throws -> T + ) async rethrows -> T { + try await $_isUndoRecordingDisabled.withValue(true) { + try await operation() + } } } diff --git a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift index 2235bbd9..12869956 100644 --- a/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift +++ b/Tests/SQLiteDataTests/UndoTests/UndoManagerTests.swift @@ -289,11 +289,11 @@ struct UndoManagerCoreTests { #expect(items.count == 1) } - @Test func withoutUndoSuppressesRecording() async throws { + @Test func writeWithoutUndoGroupSuppressesRecording() async throws { let db = try DatabaseQueue.undoDatabase() let undoManager = try UndoManager(for: db, tables: Item.self) - try await withoutUndo { + try await db.writeWithoutUndoGroup { try await undoManager.withGroup("Suppressed insert") { db in _ = try Item.insert { Item.Draft(title: "Suppressed") }.execute(db) } @@ -396,7 +396,7 @@ struct UndoManagerCoreTests { } let undoManager = try UndoManager(for: db, tables: Parent.self, Child.self) - try await withoutUndo { + try await db.writeWithoutUndoGroup { try await db.write { db in try db.execute(sql: #"INSERT INTO "parents" ("id","name") VALUES (1,'P')"#) try db.execute(sql: #"INSERT INTO "children" ("id","parentID","name") VALUES (1,1,'C')"#) From 56c8c1236a861faf59fa0c3e33ed151d33e1d780 Mon Sep 17 00:00:00 2001 From: Johan Kool Date: Fri, 27 Feb 2026 09:43:40 +0100 Subject: [PATCH 16/16] Rename writeWithoutUndoGroup source file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../{WithoutUndo.swift => DatabaseWriter+WithoutUndoGroup.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/SQLiteData/Undo/{WithoutUndo.swift => DatabaseWriter+WithoutUndoGroup.swift} (100%) diff --git a/Sources/SQLiteData/Undo/WithoutUndo.swift b/Sources/SQLiteData/Undo/DatabaseWriter+WithoutUndoGroup.swift similarity index 100% rename from Sources/SQLiteData/Undo/WithoutUndo.swift rename to Sources/SQLiteData/Undo/DatabaseWriter+WithoutUndoGroup.swift