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
Related
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:
discard, noparanoia/acts_as_paranoidpaper_trail, noaudited, nologidze. There is no history a deleted record could be recovered from.deleted_at/discarded_at/archivedcolumn anywhere indb/schema.rbdefault_scopeanywhere inapp/This is entirely greenfield. (The only
discardhits in the codebase are ActiveJob'sdiscard_oninapp/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, noschedule.rb, nosidekiq-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_atonrecords; soft delete isupdate!(deleted_at: Time.current)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_scope92 sites in
app/query records viajoins(:records)/where(records: {...})/base_scope. That's too many to convert to explicit.keptwithout 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) }, withunscopedfor the trash view. The three places that normally break underdefault_scopewere checked and all survive:admin/records_controller.rb:111unscope(:select, :order):where— scope survives ✅admin/records_controller.rb:21reselect(:id)onwide_searchconcerns/discovery_covers.rb:45Record.from("(raw sql) as records")SELECT records.*aliasedrecords, so the outer predicate resolves against its output ✅ (fragile-looking, but it holds)Two more things that happen to be fine:
UPDATE, so thesearchabletsvector trigger fires harmlesslydeleted_atisn't inRecord::POPULARITY_FIELDS, so noRecordPopularityJobis enqueued on soft deleteOne unavoidable background job
has_many_attachedpurges viapurge_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, callpurgesynchronously below a blob-count threshold and fall back topurge_laterabove 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
DELETEfor 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
deleted_atonrecords+ indexdefault_scope { where(deleted_at: nil) }, plustrashed/with_trashedscopesdefault_scopeneeds bypassingRelated