Skip to content

SQLite-based undo for apps using SQLiteData

License

Notifications You must be signed in to change notification settings

latentco/sqlite-undo

Repository files navigation

SQLiteUndo

CI

Status: This library is used in production by Aphera but is under active development. APIs may change.

SQLite-based undo/redo for Swift apps using SQLiteData and StructuredQueries. Uses database triggers to automatically capture reverse SQL for all changes to tracked tables, following the pattern described in Automatic Undo/Redo Using SQLite.

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)
  • SQLiteUndoTCAComposableArchitecture integration for UndoManager wiring in SwiftUI

Adding SQLiteUndo as a dependency

Add the following to your Package.swift:

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

Then add the product to your target's dependencies:

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

Setup

prepareDependencies {
  $0.defaultDatabase = try! appDatabase()
  $0.defaultUndoEngine = try! UndoEngine(
    for: $0.defaultDatabase,
    tables: Article.self, Author.self
  )
}

Pass any @Table types to track:

@Table
struct Article {
  let id: Int
  var name: String
}

Usage

import SQLiteUndo

try await undoable("Set Rating") {
  try await database.write { db in
    try Article.find(id).update { $0.rating = 5 }.execute(db)
  }
}

Disabling undo tracking

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

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

Application triggers

The undo system uses BEFORE triggers to capture original row values before any cascade can modify them, and reconciles duplicate entries automatically. Same-table cascading triggers (e.g., enforcing "only one row can be primary") generally work without special handling because the undo log captures all affected rows and reconciliation keeps the true originals.

However, if your app has triggers that produce side effects on other undo-tracked tables (e.g., incrementing a counter on table B when table A is updated), you must suppress them during replay with UndoEngine.isReplaying(). Otherwise the side effect fires again during undo/redo, corrupting the restored state.

Article.createTemporaryTrigger(
  after: .update { $0.status },
  forEachRow: { old, new in
    // Side effect on a different table — needs isReplaying guard
    AuditLog.insert { AuditLog(articleId: new.id, action: "updated") }
  },
  when: { old, new in
    !UndoEngine.isReplaying()
  }
)

Or in raw SQL:

CREATE TRIGGER audit_article_update
AFTER UPDATE OF "status" ON "articles"
WHEN NOT "sqliteundo_isReplaying"()
BEGIN
  INSERT INTO "auditLog" ("articleId", "action") VALUES (NEW."id", 'updated');
END

With explicit barrier management

@Dependency(\.defaultUndoEngine) var undoEngine

let barrierId = try undoEngine.beginBarrier("Set Rating")
try database.write { db in
  try Article.find(id).update { $0.rating = 5 }.execute(db)
}
try undoEngine.endBarrier(barrierId)

Undo events

After each undo/redo, UndoEngine emits an UndoEvent with the affected table rows. Use this to drive UI responses like scrolling to a restored item or switching views.

for await event in undoEngine.events() {
  if let articleIds = event.ids(for: Article.self) {
    // scroll to restored articles
  }
  if let authorIds = event.ids(for: Author.self) {
    // handle affected authors
  }
}

ids(for:) returns nil when no rows of that table were affected, so if let naturally gates your response logic.

ComposableArchitecture/SwiftUI Integration

import SQLiteUndoTCA

@Reducer
struct MyFeature {
  @ObservableState
  struct State { }

  enum Action: UndoManageableAction { // ✅ integrate the store for UndoManager registration
    case undoManager(UndoManagingAction)
    case setRating(Int)
  }

  @Dependency(\.defaultDatabase) var database

  var body: some ReducerOf<Self> {
    UndoManagingReducer()
    Reduce { state, action in
      switch action {
      case .undoManager(.event(let event)): // ✅ respond to undo/redo events
        if let articleIds = event.ids(for: Article.self) {
          // navigate to affected articles
        }
        return .none
      case .undoManager:
        return .none
      case .setRating(let rating):
        try undoable("Set Rating") { // ✅ wrap db operations in undoable
          try database.write { db in
            try Article.find(id).update { $0.rating = rating }.execute(db)
          }
        }
        return .none
      }
    }
  }
}

struct MyView: View {
  let store: StoreOf<MyFeature>
  var body: some View {
    VStack {
      // ... 
    }
    .setUndoManager(store: store) // ✅ pass the view's UndoManager to the system
  }
}

License

This library is released under the MIT license. See LICENSE for details.

About

SQLite-based undo for apps using SQLiteData

Resources

License

Stars

Watchers

Forks

Packages

No packages published