Implement Undo/Redo with Ecto

I store my application data in a DB (using Ecto).
I’d like to implement a (basic) undo/redo.

Has anybody ever done this? Any infos?

This is easy for simple changes.

defmodule Undo do
  use Agent
  import Ecto.Changeset
  # TODO get rid of this
  alias MyProject.Repo

  def start_link() do
    Agent.start_link(fn -> [] end, name: __MODULE__)
  end

  def undoable_change(data, changes) do
    changeset = change(data, changes)

    push(%{
      schema: data.__meta__.schema,
      ref_id: data.id,
      changes: Map.take(data, Map.keys(changeset.changes))
    })

    changeset
  end

  def undo() do
    case pop() do
      {:ok, undo_info} ->
        data = undo_info.schema |> Repo.get!(undo_info.ref_id)
        changeset = change(data, undo_info.changes)
        Repo.update!(changeset)

      {:no_undo_info, _} ->
        :no_undo_info
    end
  end

  def pop do
    Agent.get_and_update(__MODULE__, fn
      [undo_info | undo_stack] -> {{:ok, undo_info}, undo_stack}
      [] -> {:no_undo_info, []}
    end)
  end

  def push(undo_info) do
    Agent.update(__MODULE__, &[undo_info | &1])
  end
end

But I’m not sure how to handle references.
Obviously I can’t just delete child-records if I delete a record.
Maybe just flag a record as deleted. But then I may have to manually flag childs.

1 Like

Undo / Redo is basically never basic, especially when you’re doing it across multiple records.

Generally the traditional answer is to use an event system. You cut an event which represents a logical action, and then that event triggers changes to DB tables. Then you create an invert function that basically takes an event, and cuts a new event that is the logical inverse. Then that event triggers general changes to the db. Redo is just invert(invert(event))

You can’t meaningfully implement an undo as a simple “put the database back how it was” because you can’t undelete records, and you can’t simply put foreign keys back the way they were.

3 Likes

Yes, I thnk changeset should be a good starting point for that.
As you see its simple for basic changes.
Just wondering about the references. Thought that so would have done that with Ecto already.

Here’s the issue with changesets: they aren’t persisted. Suppose you have a record, and you make a change via a changeset to even just that one record. Then, you want to initiate an “undo”. How do you call invert(changeset) ? That changeset is gone.

1 Like

Whats wrong about the solution I posted using the Agent? I think it should work for changes not involving references. But after thinking about it a little more, I think it really gets messy with references.

I’ll have a look at this: Automatic Undo/Redo With SQLite.
At least I’ll have the opportunity to use TCL again after years. :roll_eyes:

What your describing sounds like revision history. You might want to check out paper trail.

I’ve never used it but looks like it’s based on a Ruby library of the same name.

1 Like

that looks promising. thanks.

Happy to help!

The Elixir variant is ExAudit.

I’ve thought of authoring something similar years ago but I’ve already used the library in the past and it’s working pretty well. Though I can’t remember if it works with Ecto.Multi, the docs only reference Ecto.Repo.

1 Like

I think my confusion with the agent as a persistence mechanism is a lack of clarity about how it would interface with end users. Are you expecting that the end user is using LiveView so that you can pair the agent with a given live view? If they navigate away, do they lose the ability to undo / redo?

instead of insert(change(...)) you would do undoable_change and undo. (undoable_change is missing an insert call`.

great, i’ll look at that also. What bugs me a little is that neither paper-trail nor ex-audit mention undo/redo as a usecase.

Sorry, I don’t understand. I’m asking how your code would combine a user interface where people do and undo things, with your Undo agent, from a process architecture standpoint.

Well they don’t use that language but “reverting a model to a previous state” or “tracking changes” and letting you revert then kind of speaks to that I think.

I’m at the very beginning of this, and did not think that far. I’m happy for now when I can change stuff in the DB using iex and undo/redo it afterwards.

Oh yeah that makes sense.

If you add one of those libraries as a dependency you should be able to play around with that in iex

After looking into this a little more, I decided not to use a DB as Application-file-format, but libgraph. Its a prefect fit for what I need and undo/redo I can simply do with

:erlang.term_to_binary(graph, [:compressed])

That sounds really interesting! Can you show a bit about how this would be used?

Its a kind of boring tool that automatically connects devices with a number of ports of maybe different functionality with other devices with matching ports. I can’t decide if I can show code as its not mine.