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
51 changes: 50 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,34 @@ 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)
}
}
```

### Suppressing app triggers during replay

If your app has triggers that cascade writes (e.g., updating derived state), use `UndoEngine.isReplaying()` in their WHEN clauses to prevent interference during undo/redo:

```swift
Article.createTemporaryTrigger(
after: .update { $0.rating },
forEachRow: { old, new in
// update derived state...
},
when: { old, new in
!UndoEngine.isReplaying()
}
)
```

### With explicit barrier management

```swift
Expand Down
16 changes: 0 additions & 16 deletions Sources/SQLiteUndo/UndoCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,20 +215,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
13 changes: 9 additions & 4 deletions Sources/SQLiteUndo/UndoOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,15 @@ extension Database {
// Get current max seq before executing (new entries will be added after this)
let seqBefore = try undoLogMaxSeq() ?? 0

// Execute with triggers ENABLED - this captures the reverse SQL
for entry in entries {
logger.trace("Executing SQL: \(entry.sql)")
try #sql("\(raw: entry.sql)").execute(self)
// Execute with triggers ENABLED - this captures the reverse SQL.
// Set isReplaying so app-level triggers suppress cascading writes.
// The undo log already contains all effects (including cascades),
// so replaying them individually is sufficient.
try $_undoIsReplaying.withValue(true) {
for entry in entries {
logger.trace("Executing SQL: \(entry.sql)")
try #sql("\(raw: entry.sql)").execute(self)
}
}

// Get new seq range for captured entries
Expand Down
Loading
Loading