Screencast

35. Schedule Delete

ectoliveviewobanpubsub
Unlock the full screencast for free.

Register to get the Starter Kit, every episode, and weekly deep dives.

Sign up free

Permanently delete database records after a delay — with the option to undo

Deleting a record immediately is straightforward, but it leaves no room for recovery. A common approach is to mark a record as deleted first, then remove it from the database after a short delay. This gives users a window to undo the action before it becomes permanent.

This screencast walks through building that pattern in Phoenix LiveView using Ecto, Oban, and Phoenix PubSub — from the database migration to the real-time table update when the job finally runs.

What this covers

  • Adding a deleted_at timestamp column to soft-delete records instead of removing them immediately
  • Scheduling a hard delete with Oban, with a configurable delay before permanent removal
  • Using Phoenix PubSub to broadcast deletions and update the LiveView table in real time — without a page refresh
  • Applying conditional CSS classes in the table component to visually mark deleted rows, using Tailwind's parent-targeting syntax
  • Implementing an undo function that clears deleted_at, which the Oban job checks before proceeding with deletion
  • Extending a shared table component with a row_deleted attribute without breaking other usages of the same component

A taste of what you'll build

Scheduling the hard delete through Oban after soft-deleting a record:

def soft_delete_brewery(%Brewery{} = brewery) do
changeset = Ecto.Changeset.change(brewery, %{deleted_at: DateTime.utc_now(:second)})
with {:ok, brewery} <- Repo.update(changeset),
{:ok, _job} <- schedule_hard_delete(brewery) do
{:ok, brewery}
end
end
defp schedule_hard_delete(brewery) do
%{id: brewery.id}
|> HardDeleteBreweryWorker.new(schedule_in: 60)
|> Oban.insert()
end

The Oban worker that runs the permanent delete — and handles the case where the record was already restored:

def perform(%Oban.Job{args: %{"id" => id}}) do
case Breweries.hard_delete_brewery(id) do
{:ok, _brewery} -> :ok
{:error, :not_found} -> :ok
end
end

The not_found case is intentional. If a user undoes the deletion before the job runs, deleted_at is cleared and the hard delete query finds nothing — the job completes cleanly either way.