Skip to content

Soft delete / trash for records — recoverable delete without a cron reaper #386

Description

@holden

Goal

Give record deletion an undo. Today (and after #384 ships) delete is permanent and immediate — a misclick is unrecoverable.

Not scheduled. This is a design-complete backlog item. Recorded now so the reasoning doesn't have to be re-derived, and because it changes a decision in #384 (see "Interaction with #384" below).

Nothing exists today

Audited the whole app:

  • No soft-delete gem — no discard, no paranoia/acts_as_paranoid
  • No versioning/audit gem — no paper_trail, no audited, no logidze. There is no history a deleted record could be recovered from.
  • No deleted_at / discarded_at / archived column anywhere in db/schema.rb
  • No default_scope anywhere in app/

This is entirely greenfield. (The only discard hits in the codebase are ActiveJob's discard_on in app/jobs/, which is unrelated.)

The hard part: soft → hard conversion

Every soft-delete design that needs a reaper needs it because someone specified "purge after 30 days." That retention window is the only thing that requires a clock.

And a clock is a problem here specifically: the app has zero scheduled-job machinery. No recurring.yml, no schedule.rb, no sidekiq-cron. All four existing jobs (discogs_matching_job, record_popularity_job, migrate_asset_job, application_job) are immediate-enqueue. A reaper would be the first scheduled infrastructure in the app — and a scheduled purge job that silently stops is the classic failure mode: trash grows forever and nobody notices, or worse it runs wrong and eats live data.

Resolution: drop the retention window, and the cron requirement disappears with it

Make trash a place, not a timer.

  • deleted_at on records; soft delete is update!(deleted_at: Time.current)
  • Blobs stay attached during soft delete. This is the part most designs botch — purging S3 on soft delete means "undo" restores a record with no images or audio, which isn't an undo
  • Trash page with a count badge in the nav, so it can never accumulate silently
  • Hard delete has exactly two triggers, both a synchronous button click: "Delete permanently" (per record) and "Empty trash"

The human is the reaper. No cron, no scheduled sweep, nothing that can silently die.

Why that's affordable here

Single-owner collection. At ~4 MB of blobs per record (79.1 GB across 131,004 blobs / 20,388 records), even 100 trashed records is ~400 MB against 79 GB — under half a percent. There's no storage pressure forcing automatic reaping, so the only argument for it is tidiness, and a visible counter solves tidiness better than a cron job does.

Scoping: this needs default_scope

92 sites in app/ query records via joins(:records) / where(records: {...}) / base_scope. That's too many to convert to explicit .kept without missing one — and a miss means deleted records silently reappearing in discovery pages, artist pages, or search.

So this wants default_scope { where(deleted_at: nil) }, with unscoped for the trash view. The three places that normally break under default_scope were checked and all survive:

Site Concern Verdict
admin/records_controller.rb:111 unscope(:select, :order) Doesn't touch :where — scope survives ✅
admin/records_controller.rb:21 reselect(:id) on wide_search Scope still applies ✅
concerns/discovery_covers.rb:45 Record.from("(raw sql) as records") Subquery is SELECT records.* aliased records, so the outer predicate resolves against its output ✅ (fragile-looking, but it holds)

Two more things that happen to be fine:

  • Soft delete is an UPDATE, so the searchable tsvector trigger fires harmlessly
  • deleted_at isn't in Record::POPULARITY_FIELDS, so no RecordPopularityJob is enqueued on soft delete

One unavoidable background job

has_many_attached purges via purge_later (Sidekiq) — already true today. It's immediate-enqueue, not scheduled-for-later, so it doesn't carry the silent-death risk. If a fully-inline empty-trash is wanted, call purge synchronously below a blob-count threshold and fall back to purge_later above it.

If a time bound is ever wanted

Sweep opportunistically, not on a schedule: when the owner soft-deletes a record, that same request first hard-deletes anything in trash older than N days. Bounded, inline, no clock. Failure mode is benign — if you never delete again, trash just sits there, visible in the badge.

Interaction with #384

#384's Tier B confirmation (typed DELETE for records with media) exists precisely because there is no undo. If trash ships, Tier B can relax to a single click, since delete becomes recoverable.

#384 should build Tier B so that relaxation is a copy change, not a rewrite.

Sketch tasks

  • Migration: deleted_at on records + index
  • default_scope { where(deleted_at: nil) }, plus trashed / with_trashed scopes
  • Soft delete replaces hard delete on the owner-facing path
  • Trash page: list, restore, delete permanently
  • Trash count badge in the nav
  • "Empty trash" with its own confirmation
  • Audit the 92 query sites for anywhere default_scope needs bypassing
  • Relax Record deletion: unreachable from the UI, mis-describes its own blast radius, and needs media-aware confirmation #384's Tier B typed confirmation to a single click
  • Tests: trashed records absent from index, search, discovery, artist/label/genre pages; restore round-trips including attachments

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions