Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
C441694F2F38F3B100051412 /* SQLiteUndo in Frameworks */ = {isa = PBXBuildFile; productRef = C441694E2F38F3B100051412 /* SQLiteUndo */; };
C44169512F38F3B100051412 /* SQLiteUndoTCA in Frameworks */ = {isa = PBXBuildFile; productRef = C44169502F38F3B100051412 /* SQLiteUndoTCA */; };
C45245EF2F3D353800F31BB8 /* SQLiteUndo in Frameworks */ = {isa = PBXBuildFile; productRef = C45245EE2F3D353800F31BB8 /* SQLiteUndo */; };
C45245F12F3D353800F31BB8 /* SQLiteUndoTCA in Frameworks */ = {isa = PBXBuildFile; productRef = C45245F02F3D353800F31BB8 /* SQLiteUndoTCA */; };
C4B1976A2F33B52B001EAFC2 /* SQLiteUndo in Frameworks */ = {isa = PBXBuildFile; productRef = C4B197692F33B52B001EAFC2 /* SQLiteUndo */; };
C4B197712F33B5D9001EAFC2 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = C4B197702F33B5D9001EAFC2 /* ComposableArchitecture */; };
C4B197F02F33C28A001EAFC2 /* SQLiteUndo in Frameworks */ = {isa = PBXBuildFile; productRef = C4B197EF2F33C28A001EAFC2 /* SQLiteUndo */; };
Expand All @@ -35,11 +37,13 @@
buildActionMask = 2147483647;
files = (
C441694F2F38F3B100051412 /* SQLiteUndo in Frameworks */,
C45245EF2F3D353800F31BB8 /* SQLiteUndo in Frameworks */,
C4B197712F33B5D9001EAFC2 /* ComposableArchitecture in Frameworks */,
C4B197F22F33C28A001EAFC2 /* SQLiteUndoTCA in Frameworks */,
C4B197F52F33C2B0001EAFC2 /* SQLiteUndo in Frameworks */,
C4B197F02F33C28A001EAFC2 /* SQLiteUndo in Frameworks */,
C44169512F38F3B100051412 /* SQLiteUndoTCA in Frameworks */,
C45245F12F3D353800F31BB8 /* SQLiteUndoTCA in Frameworks */,
C4B1976A2F33B52B001EAFC2 /* SQLiteUndo in Frameworks */,
C4B197F72F33C2B0001EAFC2 /* SQLiteUndoTCA in Frameworks */,
);
Expand Down Expand Up @@ -92,6 +96,8 @@
C4B197F62F33C2B0001EAFC2 /* SQLiteUndoTCA */,
C441694E2F38F3B100051412 /* SQLiteUndo */,
C44169502F38F3B100051412 /* SQLiteUndoTCA */,
C45245EE2F3D353800F31BB8 /* SQLiteUndo */,
C45245F02F3D353800F31BB8 /* SQLiteUndoTCA */,
);
productName = UndoForMacOS;
productReference = C4B1975D2F33B502001EAFC2 /* UndoForMacOS.app */;
Expand Down Expand Up @@ -123,7 +129,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
C4B1976F2F33B5D9001EAFC2 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */,
C441694D2F38F3B100051412 /* XCLocalSwiftPackageReference "../../sqlite-undo" */,
C45245ED2F3D353800F31BB8 /* XCLocalSwiftPackageReference "../../sqlite-undo" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = C4B197462F33B4B4001EAFC2 /* Products */;
Expand Down Expand Up @@ -364,7 +370,7 @@
/* End XCConfigurationList section */

/* Begin XCLocalSwiftPackageReference section */
C441694D2F38F3B100051412 /* XCLocalSwiftPackageReference "../../sqlite-undo" */ = {
C45245ED2F3D353800F31BB8 /* XCLocalSwiftPackageReference "../../sqlite-undo" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../../sqlite-undo";
};
Expand All @@ -390,6 +396,14 @@
isa = XCSwiftPackageProductDependency;
productName = SQLiteUndoTCA;
};
C45245EE2F3D353800F31BB8 /* SQLiteUndo */ = {
isa = XCSwiftPackageProductDependency;
productName = SQLiteUndo;
};
C45245F02F3D353800F31BB8 /* SQLiteUndoTCA */ = {
isa = XCSwiftPackageProductDependency;
productName = SQLiteUndoTCA;
};
C4B197692F33B52B001EAFC2 /* SQLiteUndo */ = {
isa = XCSwiftPackageProductDependency;
productName = SQLiteUndo;
Expand Down
54 changes: 37 additions & 17 deletions Examples/UndoForMacOS/UndoForMacOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct DemoFeature {
case undoManager(UndoManagingAction)
case addItem
case addItemInBackground
case addItemWithoutTracking
case addUntrackedItem
case incrementCount(Int)
case incrementAll
Expand Down Expand Up @@ -75,6 +76,17 @@ struct DemoFeature {
}
}

case .addItemWithoutTracking:
withErrorReporting {
try withUndoDisabled {
try database.write { db in
let nextID = (try DemoItem.all.fetchAll(db).map(\.id).max() ?? 0) + 1
try DemoItem.insert { DemoItem(id: nextID, name: "Item \(nextID)") }.execute(db)
}
}
}
return .none

case .addUntrackedItem:
withErrorReporting {
try undoable("Add Untracked Item") {
Expand Down Expand Up @@ -161,25 +173,33 @@ struct DemoView: View {
}
.frame(minHeight: 200)

HStack {
Button("Add Item") {
store.send(.addItem)
}
.buttonStyle(.borderedProminent)
Button("Add Item (Background)") {
store.send(.addItemInBackground)
}
.buttonStyle(.bordered)
Button("Increment All") {
store.send(.incrementAll)
VStack {
HStack {
Button("Add Item") {
store.send(.addItem)
}
.buttonStyle(.borderedProminent)
Button("Increment All") {
store.send(.incrementAll)
}
.buttonStyle(.bordered)
.disabled(store.items.isEmpty)
}
.buttonStyle(.bordered)
.disabled(store.items.isEmpty)
Divider()
Button("Add Untracked Item") {
store.send(.addUntrackedItem)
HStack {
Button("Add Item without tracking") {
store.send(.addItemWithoutTracking)
}
.buttonStyle(.bordered)
Button("Add Item (Background)") {
store.send(.addItemInBackground)
}
.buttonStyle(.bordered)
Button("Add Untracked Item") {
store.send(.addUntrackedItem)
}
.buttonStyle(.bordered)
}
.buttonStyle(.bordered)
.fixedSize()
}
}
.padding()
Expand Down
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,28 @@

[![CI](https://github.com/latentco/sqlite-undo/actions/workflows/ci.yml/badge.svg)](https://github.com/latentco/sqlite-undo/actions/workflows/ci.yml)

SQLite-based undo/redo for Swift apps using [SQLiteData](https://github.com/pointfreeco/sqlite-data). Uses database triggers to capture changes automatically using the pattern described in [Automatic Undo/Redo Using SQLite](https://www.sqlite.org/undoredo.html)
SQLite-based undo/redo for Swift apps using [SQLiteData](https://github.com/pointfreeco/sqlite-data) and [StructuredQueries](https://github.com/pointfreeco/swift-structured-queries). Uses database triggers to automatically capture reverse SQL for all changes to tracked tables, following the pattern described in [Automatic Undo/Redo Using SQLite](https://www.sqlite.org/undoredo.html).

Changes are grouped into barriers that represent single user actions (e.g., "Set Rating", "Delete Item"). Barriers integrate with `NSUndoManager` so undo/redo works with the standard Edit menu, keyboard shortcuts, and shake-to-undo.

Two libraries are provided:

- **SQLiteUndo** — core undo engine, barriers, and free functions (`undoable`, `withUndoDisabled`)
- **SQLiteUndoTCA** — [ComposableArchitecture](https://github.com/pointfreeco/swift-composable-architecture) integration for `UndoManager` wiring in SwiftUI

## Adding SQLiteUndo as a dependency

Add the following to your `Package.swift`:

```swift
.package(url: "https://github.com/latentco/sqlite-undo.git", from: "0.1.0"),
```

Then add the product to your target's dependencies:

```swift
.product(name: "SQLiteUndo", package: "sqlite-undo"),
```

## Setup

Expand Down Expand Up @@ -38,6 +59,49 @@ try await undoable("Set Rating") {
}
```

### Disabling undo tracking

Use `withUndoDisabled` for operations that shouldn't be undoable (e.g., batch imports, programmatic state rebuilds):

```swift
try withUndoDisabled {
try database.write { db in
try Article.insert { Article(id: 1, name: "Imported") }.execute(db)
}
}
```

### Application triggers

If your app has triggers that cascade writes (e.g., clearing a flag on other rows, updating derived state), they **must** include `UndoEngine.isReplaying()` in their WHEN clause:

```swift
Article.createTemporaryTrigger(
after: .update { $0.isPrimary },
forEachRow: { old, new in
// Clear isPrimary on all other rows
Article.where { $0.id != new.id }
.update { $0.isPrimary = false }
},
when: { old, new in
!UndoEngine.isReplaying()
}
)
```

Or in raw SQL:

```sql
CREATE TRIGGER clear_primary
AFTER UPDATE OF "isPrimary" ON "articles"
WHEN NOT "sqliteundo_isReplaying"()
BEGIN
UPDATE "articles" SET "isPrimary" = 0 WHERE "id" != NEW."id";
END
```

> **Note:** The undo system uses BEFORE triggers to capture original values and records all effects of a change (including cascades) in the undo log. During undo/redo replay, each effect is replayed individually, so cascade triggers must be suppressed to avoid corrupting the restored state. Without the `isReplaying` guard, a cascade trigger would fire again during replay and overwrite values that the undo system is trying to restore.

### With explicit barrier management

```swift
Expand Down
26 changes: 9 additions & 17 deletions Sources/SQLiteUndo/UndoCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ final class UndoCoordinator: Sendable {
return nil
}

return try database.read { db in
return try database.write { db in
guard let endSeq = try db.undoLogMaxSeq(), endSeq >= openBarrier.startSeq else {
let tables = registeredTables.sorted()
logger.warning(
Expand All @@ -110,6 +110,14 @@ final class UndoCoordinator: Sendable {
return nil
}

// Reconcile duplicate entries from cascading BEFORE triggers
try db.reconcileUndoLogEntries(from: openBarrier.startSeq, to: endSeq)

// Re-read endSeq since reconciliation may have removed entries
guard let endSeq = try db.undoLogMaxSeq(), endSeq >= openBarrier.startSeq else {
return nil
}

let barrier = UndoBarrier(
id: id,
name: openBarrier.name,
Expand Down Expand Up @@ -215,20 +223,4 @@ final class UndoCoordinator: Sendable {
}
}
}

/// Temporarily disable undo tracking.
///
/// Use this for bulk operations, migrations, or imports where you don't
/// want individual changes tracked.
func withUndoDisabled<T>(_ operation: () throws -> T) throws -> T {
try database.write { db in
try UndoState.find(1).update { $0.isActive = false }.execute(db)
}
defer {
try? database.write { db in
try UndoState.find(1).update { $0.isActive = true }.execute(db)
}
}
return try operation()
}
}
80 changes: 55 additions & 25 deletions Sources/SQLiteUndo/UndoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,30 @@ private let logger = Logger(subsystem: "SQLiteUndo", category: "UndoEngine")
/// $0.defaultUndoStack = .live(windowUndoManager)
/// $0.defaultUndoEngine = try! UndoEngine(
/// for: $0.defaultDatabase,
/// tables: ProjectItem.self, ProjectEdit.self
/// tables: Item.self, Edit.self
/// )
/// }
/// ```
///
/// ## Usage
///
/// Wrap database changes in ``undoable(_:operation:)-3cgh0`` to make them undoable:
///
/// ```swift
/// @Dependency(\.defaultUndoEngine) var undoEngine
/// try undoable("Set Rating") {
/// try database.write { db in
/// try Item.find(id).update { $0.rating = rating }.execute(db)
/// }
/// }
/// ```
///
/// // Simple operation
/// let barrierId = try undoEngine.beginBarrier("Set Rating")
/// try database.write { /* make changes */ }
/// try undoEngine.endBarrier(barrierId)
/// Use ``withUndoDisabled(_:)`` for operations that shouldn't be tracked:
///
/// // With error handling
/// do {
/// let barrierId = try undoEngine.beginBarrier("Set Rating")
/// try database.write { /* make changes */ }
/// try undoEngine.endBarrier(barrierId)
/// } catch {
/// try undoEngine.cancelBarrier(barrierId)
/// throw error
/// ```swift
/// try withUndoDisabled {
/// try database.write { db in
/// try Item.insert { Item(id: 1, name: "Imported") }.execute(db)
/// }
/// }
/// ```
@DependencyClient
Expand All @@ -65,15 +66,41 @@ public struct UndoEngine: Sendable {
///
/// - Parameter id: The barrier ID from `beginBarrier`
public var cancelBarrier: @Sendable (_ id: UUID) throws -> Void
}

/// Whether undo tracking is active. Default true; set false inside `withUndoDisabled`.
@TaskLocal var _undoIsActive = true

/// Whether the undo system is replaying entries (undo/redo in progress).
@TaskLocal var _undoIsReplaying = false

@DatabaseFunction("sqliteundo_isActive")
func undoIsActiveFunction() -> Bool {
_undoIsActive
}

/// Temporarily disable undo tracking for an operation.
@DatabaseFunction("sqliteundo_isReplaying")
func undoIsReplayingFunction() -> Bool {
_undoIsReplaying
}

extension UndoEngine {
/// A SQL expression that evaluates to true when the undo system is replaying entries.
///
/// Use for migrations, bulk imports, or other operations that shouldn't
/// be individually undoable.
/// Use `!UndoEngine.isReplaying()` in application trigger WHEN clauses to suppress
/// cascading writes during undo/redo replay:
///
/// - Parameter operation: The operation to perform without tracking
public var withUndoDisabled: @Sendable (_ operation: () throws -> Void) throws -> Void = {
try $0()
/// ```swift
/// Table.createTemporaryTrigger(
/// after: .update { $0.isSelected }
/// forEachRow: { old, new in ... }
/// when: { old, new in
/// someCondition.and(!UndoEngine.isReplaying())
/// }
/// )
/// ```
public static func isReplaying() -> some QueryExpression<Bool> {
$undoIsReplayingFunction()
}
}

Expand Down Expand Up @@ -104,7 +131,10 @@ extension UndoEngine {
let registeredNames = Set(tables.map { $0.tableName })
let untrackedNames = Set(untracked.map { $0.tableName })
self = .make(
database: database, registeredTables: registeredNames, untrackedTables: untrackedNames)
database: database,
registeredTables: registeredNames,
untrackedTables: untrackedNames
)
}

/// Create an UndoEngine for a database with the specified tracked tables.
Expand All @@ -126,7 +156,10 @@ extension UndoEngine {
let registeredNames = Set(tables.map { $0.tableName })
let untrackedNames = Set(untracked.map { $0.tableName })
self = .make(
database: database, registeredTables: registeredNames, untrackedTables: untrackedNames)
database: database,
registeredTables: registeredNames,
untrackedTables: untrackedNames
)
}

private static func install(for database: any DatabaseWriter, tables: [any Table.Type])
Expand Down Expand Up @@ -191,9 +224,6 @@ extension UndoEngine: DependencyKey {
},
cancelBarrier: { id in
try coordinator.cancelBarrier(id)
},
withUndoDisabled: { operation in
try coordinator.withUndoDisabled(operation)
}
)
}
Expand Down
Loading
Loading