Skip to main content

max / audiofiles

Sample deletion: tombstone operations, Trash UI, startup sweep (phases 2-4) Wire the soft-delete path on top of the M019 schema: tombstone_sample/ undelete_sample plus an unfiltered-extension fix so remove/purge work on tombstoned rows; a Trash section in Settings (list, restore, confirm-gated purge); and sweep_expired_tombstones at startup hard-deleting past-retention rows. The samples DELETE is not sync-suppressed, so it propagates and receiving devices CASCADE their own (symmetrically tombstoned) data. Phase 5 (pull-side "deleted on another device" notice) remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-20 00:21 UTC
Commit: e9d8ac24821d0c1667a8cf0a5e3bf08717b6a8dd
Parent: 3057e11
10 files changed, +555 insertions, -11 deletions
@@ -84,6 +84,15 @@ pub struct DirectBackend {
84 84 impl DirectBackend {
85 85 /// Create a new DirectBackend from a database and sample store.
86 86 pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self {
87 + // One-time maintenance: hard-delete tombstones past their retention
88 + // window (docs/design-sample-deletion.md Phase 4). Best-effort — a
89 + // failed sweep must never block app start, so errors are logged, not
90 + // propagated. Runs against the owned db before it moves into the Mutex.
91 + match store.sweep_expired_tombstones(&db) {
92 + Ok(0) => {}
93 + Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"),
94 + Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"),
95 + }
87 96 Self {
88 97 db: Mutex::new(db),
89 98 store,
@@ -915,6 +924,27 @@ impl Backend for DirectBackend {
915 924 Ok(self.store.remove_orphaned_samples(&db)?)
916 925 }
917 926
927 + fn list_tombstoned(&self) -> BackendResult<Vec<audiofiles_core::store::TombstonedSample>> {
928 + let db = self.db.lock();
929 + Ok(audiofiles_core::store::tombstoned_samples(&db)?)
930 + }
931 +
932 + fn undelete_sample(&self, hash: &str) -> BackendResult<()> {
933 + let db = self.db.lock();
934 + audiofiles_core::store::undelete_sample(&db, hash)?;
935 + Ok(())
936 + }
937 +
938 + fn purge_sample(&self, hash: &str) -> BackendResult<()> {
939 + let db = self.db.lock();
940 + Ok(self.store.remove(hash, &db)?)
941 + }
942 +
943 + fn sweep_expired_tombstones(&self) -> BackendResult<usize> {
944 + let db = self.db.lock();
945 + Ok(self.store.sweep_expired_tombstones(&db)?)
946 + }
947 +
918 948 fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>> {
919 949 let db = self.db.lock();
920 950 Ok(audiofiles_core::store::sample_source_path(&db, hash)?)
@@ -22,6 +22,7 @@ use audiofiles_core::export::{ExportConfig, ExportItem};
22 22 use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget};
23 23 use audiofiles_core::search::SearchFilter;
24 24 use audiofiles_core::collections::Collection;
25 + use audiofiles_core::store::TombstonedSample;
25 26 use audiofiles_core::vfs::{Vfs, VfsNode, VfsNodeWithAnalysis};
26 27 use audiofiles_core::{CollectionId, NodeId, VfsId};
27 28
@@ -603,6 +604,24 @@ pub trait Backend: Send + Sync {
603 604 /// to others (see `docs/design-sample-deletion.md`).
604 605 fn cleanup_orphans_local(&self) -> BackendResult<usize>;
605 606
607 + // --- Trash (soft-deleted samples) ---
608 +
609 + /// List every tombstoned sample, most recently deleted first. Backs the
610 + /// Trash section of Settings (see `docs/design-sample-deletion.md`).
611 + fn list_tombstoned(&self) -> BackendResult<Vec<TombstonedSample>>;
612 +
613 + /// Restore a tombstoned sample, clearing its `deleted_at`. The clear
614 + /// replicates, so the sample returns on every device.
615 + fn undelete_sample(&self, hash: &str) -> BackendResult<()>;
616 +
617 + /// Permanently delete a tombstoned sample now, skipping the retention
618 + /// window: removes the blob and the row (CASCADE handles placements/tags).
619 + fn purge_sample(&self, hash: &str) -> BackendResult<()>;
620 +
621 + /// Hard-delete tombstones whose retention window has expired. Called once at
622 + /// startup; returns the number of samples swept.
623 + fn sweep_expired_tombstones(&self) -> BackendResult<usize>;
624 +
606 625 /// Look up the source_path for an loose-files mode sample. Returns None for normal samples.
607 626 fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>>;
608 627
@@ -93,6 +93,45 @@ impl BrowserState {
93 93 }
94 94 }
95 95
96 + /// Reload the cached Trash list from the backend.
97 + pub fn refresh_trash(&mut self) {
98 + self.settings.trash = self.backend.list_tombstoned().unwrap_or_default();
99 + self.settings.trash_loaded = true;
100 + }
101 +
102 + /// Ensure the Trash list is loaded at least once (called when the section
103 + /// opens).
104 + pub fn ensure_trash_loaded(&mut self) {
105 + if !self.settings.trash_loaded {
106 + self.refresh_trash();
107 + }
108 + }
109 +
110 + /// Restore a tombstoned sample from Trash. Refreshes the Trash list and the
111 + /// browse contents so the recovered sample reappears immediately.
112 + pub fn undelete_sample(&mut self, hash: &str) {
113 + match self.backend.undelete_sample(hash) {
114 + Ok(()) => {
115 + self.status = "Restored sample from Trash.".to_string();
116 + self.refresh_trash();
117 + self.refresh_contents();
118 + }
119 + Err(e) => self.status = format!("Restore failed: {e}"),
120 + }
121 + }
122 +
123 + /// Permanently delete a tombstoned sample, skipping the retention window.
124 + /// Refreshes the Trash list.
125 + pub fn purge_sample(&mut self, hash: &str) {
126 + match self.backend.purge_sample(hash) {
127 + Ok(()) => {
128 + self.status = "Deleted sample permanently.".to_string();
129 + self.refresh_trash();
130 + }
131 + Err(e) => self.status = format!("Permanent delete failed: {e}"),
132 + }
133 + }
134 +
96 135 /// Dismiss the first-launch hint and persist the preference.
97 136 pub fn dismiss_first_launch_hint(&mut self) {
98 137 self.show_first_launch_hint = false;
@@ -275,6 +275,17 @@ pub struct SettingsUiState {
275 275 pub machine_id: Option<String>,
276 276 /// Trial days remaining (None if not in trial mode).
277 277 pub trial_days_remaining: Option<i64>,
278 +
279 + /// Cached list of tombstoned samples shown in the Trash section, most
280 + /// recently deleted first. Refreshed when the section opens and after an
281 + /// undelete or permanent-delete.
282 + pub trash: Vec<audiofiles_core::store::TombstonedSample>,
283 + /// True once `trash` has been loaded at least once this session.
284 + pub trash_loaded: bool,
285 + /// Hash of the sample whose permanent-delete is awaiting confirmation, if
286 + /// any. Permanent delete is irreversible, so the first click arms this and
287 + /// the row swaps to a confirm/cancel pair.
288 + pub trash_confirm_purge: Option<String>,
278 289 }
279 290
280 291 /// GUI state for the tag-classifier sections in Settings (Layer A rules builder).
@@ -31,6 +31,8 @@ pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) {
31 31 ui.add_space(theme::space::SM);
32 32 draw_license_section(ui, state);
33 33 ui.add_space(theme::space::SM);
34 + draw_trash_section(ui, state);
35 + ui.add_space(theme::space::SM);
34 36 draw_advanced_section(ui, state);
35 37 });
36 38 },
@@ -703,6 +705,147 @@ fn draw_license_section(ui: &mut egui::Ui, state: &mut BrowserState) {
703 705
704 706 // ── Advanced section ──
705 707
708 + /// Format how long ago a sample was tombstoned, e.g. "deleted 3 days ago".
709 + fn format_deleted_age(age_secs: i64) -> String {
710 + let age = age_secs.max(0);
711 + if age < 120 {
712 + "deleted just now".to_string()
713 + } else if age < 3_600 {
714 + format!("deleted {} minutes ago", age / 60)
715 + } else if age < 86_400 {
716 + let h = age / 3_600;
717 + format!("deleted {h} hour{} ago", if h == 1 { "" } else { "s" })
718 + } else {
719 + let d = age / 86_400;
720 + format!("deleted {d} day{} ago", if d == 1 { "" } else { "s" })
721 + }
722 + }
723 +
724 + /// Draw the "Trash" section: tombstoned samples recoverable until the retention
725 + /// window expires. Restore brings a sample back on every device; permanent
726 + /// delete (behind a per-row confirm) skips the window. The sweep at startup
727 + /// removes anything past the window automatically. See
728 + /// `docs/design-sample-deletion.md`.
729 + fn draw_trash_section(ui: &mut egui::Ui, state: &mut BrowserState) {
730 + egui::CollapsingHeader::new(egui::RichText::new("Trash").strong())
731 + .default_open(false)
732 + .show(ui, |ui| {
733 + state.ensure_trash_loaded();
734 +
735 + ui.label(
736 + egui::RichText::new(
737 + "Deleted samples are kept here for 30 days, then removed permanently. \
738 + Restoring brings a sample back on all your devices.",
739 + )
740 + .small()
741 + .color(theme::content_secondary()),
742 + );
743 + ui.add_space(theme::space::SM);
744 +
745 + if state.settings.trash.is_empty() {
746 + ui.label(
747 + egui::RichText::new("Trash is empty.")
748 + .small()
749 + .color(theme::content_muted()),
750 + );
751 + return;
752 + }
753 +
754 + let now = std::time::SystemTime::now()
755 + .duration_since(std::time::UNIX_EPOCH)
756 + .map(|d| d.as_secs() as i64)
757 + .unwrap_or(0);
758 +
759 + // The button handlers can't mutate `state` while we iterate the
760 + // borrowed trash list, so they record an action applied afterward.
761 + enum TrashAction {
762 + Undelete(String),
763 + ArmPurge(String),
764 + CancelPurge,
765 + ConfirmPurge(String),
766 + }
767 + let mut action: Option<TrashAction> = None;
768 +
769 + let trash = state.settings.trash.clone();
770 + let confirm = state.settings.trash_confirm_purge.clone();
771 +
772 + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
773 + for entry in &trash {
774 + ui.horizontal(|ui| {
775 + let name = if entry.file_extension.is_empty() {
776 + entry.original_name.clone()
777 + } else {
778 + format!("{}.{}", entry.original_name, entry.file_extension)
779 + };
780 + ui.label(&name).on_hover_text(&entry.hash);
781 + ui.label(
782 + egui::RichText::new(format!(
783 + "{} \u{b7} {}",
784 + format_bytes(entry.file_size.max(0) as u64),
785 + format_deleted_age(now - entry.deleted_at),
786 + ))
787 + .small()
788 + .color(theme::content_muted()),
789 + );
790 +
791 + ui.with_layout(
792 + egui::Layout::right_to_left(egui::Align::Center),
793 + |ui| {
794 + if confirm.as_deref() == Some(entry.hash.as_str()) {
795 + if ui.small_button("Cancel").clicked() {
796 + action = Some(TrashAction::CancelPurge);
797 + }
798 + if ui
799 + .small_button(
800 + egui::RichText::new("Delete forever")
801 + .color(theme::danger()),
802 + )
803 + .clicked()
804 + {
805 + action =
806 + Some(TrashAction::ConfirmPurge(entry.hash.clone()));
807 + }
808 + } else {
809 + if ui
810 + .small_button("Delete permanently")
811 + .on_hover_text(
812 + "Remove now, skipping the 30-day window. \
813 + Cannot be undone.",
814 + )
815 + .clicked()
816 + {
817 + action = Some(TrashAction::ArmPurge(entry.hash.clone()));
818 + }
819 + if ui.small_button("Restore").clicked() {
820 + action = Some(TrashAction::Undelete(entry.hash.clone()));
821 + }
822 + }
823 + },
824 + );
825 + });
826 + }
827 + });
828 +
829 + match action {
830 + Some(TrashAction::Undelete(hash)) => {
831 + state.settings.trash_confirm_purge = None;
832 + state.undelete_sample(&hash);
833 + }
834 + Some(TrashAction::ArmPurge(hash)) => {
835 + state.settings.trash_confirm_purge = Some(hash);
836 + }
837 + Some(TrashAction::CancelPurge) => {
838 + state.settings.trash_confirm_purge = None;
839 + }
840 + Some(TrashAction::ConfirmPurge(hash)) => {
841 + state.settings.trash_confirm_purge = None;
842 + state.purge_sample(&hash);
843 + }
844 + None => {}
845 + }
846 + });
847 + }
848 +
706 849 fn draw_advanced_section(ui: &mut egui::Ui, state: &mut BrowserState) {
707 850 egui::CollapsingHeader::new(egui::RichText::new("Advanced").strong())
708 851 .default_open(false)
@@ -223,7 +223,7 @@ fn draw_subscription_section(
223 223 // Cap-change slider for subscribed users.
224 224 if let Some(pricing) = &sync_status.pricing {
225 225 let pricing = pricing.clone();
226 - let interval_enum = BillingInterval::from_str(interval);
226 + let interval_enum = BillingInterval::from_wire(interval);
227 227 ui.add_space(theme::space::MD);
228 228 ui.label(egui::RichText::new("Adjust cap (takes effect next cycle):").weak());
229 229 if let Some(cap) = draw_cap_picker(ui, state, &pricing, interval_enum, "Update cap") {
@@ -259,7 +259,12 @@ impl SampleStore {
259 259 /// disk waste invisible to the user.
260 260 #[instrument(skip_all)]
261 261 pub fn remove(&self, hash: &str, db: &Database) -> Result<()> {
262 - let ext = sample_extension(db, hash)?;
262 + // Resolve the extension without the `deleted_at IS NULL` filter: this is
263 + // the hard-delete path, and the row may be a tombstone (purged from the
264 + // Trash view, or swept past its retention window). The blob name is
265 + // content-addressed and identical regardless of tombstone state, so the
266 + // unfiltered lookup is the correct one for locating the file to unlink.
267 + let ext = sample_extension_any(db, hash)?;
263 268 let path = self.sample_path(hash, &ext)?;
264 269
265 270 // File first. ENOENT is fine — the row was already pointing at nothing.
@@ -356,6 +361,41 @@ impl SampleStore {
356 361 Ok(deleted)
357 362 }
358 363
364 + /// Hard-delete tombstoned samples whose retention window has expired.
365 + ///
366 + /// A sample tombstoned more than `sample_tombstone_retain_days` ago (default
367 + /// 30) is past recovery: this removes its blob and its `samples` row, letting
368 + /// the engine CASCADE clean up placements, tags, and collection memberships.
369 + /// Unlike the local orphan GC, the `samples` DELETE is **not** sync-suppressed
370 + /// — the deletion propagates so the user's other devices converge (each having
371 + /// had the same window to recover). Runs once at startup; the indexed
372 + /// `deleted_at` partial index keeps the scan cheap when nothing has expired.
373 + ///
374 + /// A blob/row removal that fails for one sample is logged and skipped so a
375 + /// single stuck file (in-use on Windows, permission denied) doesn't abort the
376 + /// rest of the sweep. Returns the number of samples hard-deleted.
377 + #[instrument(skip_all)]
378 + pub fn sweep_expired_tombstones(&self, db: &Database) -> Result<usize> {
379 + let cutoff = unix_now() - tombstone_retain_days(db) * 86_400;
380 +
381 + let mut stmt = db.conn().prepare(
382 + "SELECT hash FROM samples WHERE deleted_at IS NOT NULL AND deleted_at < ?1",
383 + )?;
384 + let expired: Vec<String> = stmt
385 + .query_map([cutoff], |row| row.get(0))?
386 + .collect::<std::result::Result<Vec<_>, _>>()?;
387 + drop(stmt);
388 +
389 + let mut removed = 0usize;
390 + for hash in &expired {
391 + match self.remove(hash, db) {
392 + Ok(()) => removed += 1,
393 + Err(e) => tracing::warn!(hash = %hash, "tombstone sweep: failed to purge sample: {e}"),
394 + }
395 + }
396 + Ok(removed)
397 + }
398 +
359 399 /// Get the store root directory.
360 400 pub fn root(&self) -> &Path {
361 401 &self.root
@@ -416,11 +456,109 @@ pub fn sample_extension(db: &Database, hash: &str) -> Result<String> {
416 456 query_sample_field(db, hash, "file_extension")
417 457 }
418 458
459 + /// Look up the file extension for a sample by its hash, including tombstoned
460 + /// rows. Used by the hard-delete and sweep paths, which must resolve the blob
461 + /// path for soft-deleted rows that `sample_extension` deliberately hides.
462 + fn sample_extension_any(db: &Database, hash: &str) -> Result<String> {
463 + db.conn()
464 + .query_row(
465 + "SELECT file_extension FROM samples WHERE hash = ?1",
466 + [hash],
467 + |row| row.get(0),
468 + )
469 + .map_err(|e| match e {
470 + rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()),
471 + other => CoreError::Db(other),
472 + })
473 + }
474 +
419 475 /// Look up the original filename for a sample by its hash.
420 476 pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> {
421 477 query_sample_field(db, hash, "original_name")
422 478 }
423 479
480 + // --- Soft delete (tombstones) ---
481 + //
482 + // See `docs/design-sample-deletion.md`. A non-NULL `samples.deleted_at` marks a
483 + // sample as tombstoned: hidden from every normal read path, recoverable from the
484 + // Trash view, and hard-deleted by the sweep once it ages past the retention
485 + // window. None of these operations suppress sync triggers, so a tombstone, an
486 + // undelete, and a permanent purge each replicate to the user's other devices.
487 +
488 + /// A tombstoned sample, as shown in the Trash view.
489 + #[derive(Debug, Clone)]
490 + pub struct TombstonedSample {
491 + pub hash: String,
492 + pub original_name: String,
493 + pub file_extension: String,
494 + pub file_size: i64,
495 + /// Unix timestamp (seconds) the sample was tombstoned.
496 + pub deleted_at: i64,
497 + }
498 +
499 + /// List every tombstoned sample, most recently deleted first.
500 + pub fn tombstoned_samples(db: &Database) -> Result<Vec<TombstonedSample>> {
501 + let mut stmt = db.conn().prepare(
502 + "SELECT hash, original_name, file_extension, file_size, deleted_at
503 + FROM samples
504 + WHERE deleted_at IS NOT NULL
505 + ORDER BY deleted_at DESC",
506 + )?;
507 + let rows = stmt
508 + .query_map([], |row| {
509 + Ok(TombstonedSample {
510 + hash: row.get(0)?,
511 + original_name: row.get(1)?,
512 + file_extension: row.get(2)?,
513 + file_size: row.get(3)?,
514 + deleted_at: row.get(4)?,
515 + })
516 + })?
517 + .collect::<std::result::Result<Vec<_>, _>>()?;
518 + Ok(rows)
519 + }
520 +
521 + /// Soft-delete a sample: set `deleted_at` to now if it is currently live. The
522 + /// blob and every placement, tag, and collection membership stay intact so the
523 + /// user can recover from Trash until the sweep window expires. Returns `true` if
524 + /// a live row was tombstoned, `false` if the hash was unknown or already gone.
525 + #[instrument(skip_all)]
526 + pub fn tombstone_sample(db: &Database, hash: &str) -> Result<bool> {
527 + let n = db.conn().execute(
528 + "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2 AND deleted_at IS NULL",
529 + rusqlite::params![unix_now(), hash],
530 + )?;
531 + Ok(n > 0)
532 + }
533 +
534 + /// Restore a tombstoned sample, clearing `deleted_at`. Returns `true` if a
535 + /// tombstoned row was restored, `false` if the hash was unknown or already live.
536 + #[instrument(skip_all)]
537 + pub fn undelete_sample(db: &Database, hash: &str) -> Result<bool> {
538 + let n = db.conn().execute(
539 + "UPDATE samples SET deleted_at = NULL WHERE hash = ?1 AND deleted_at IS NOT NULL",
540 + [hash],
541 + )?;
542 + Ok(n > 0)
543 + }
544 +
545 + /// Read the tombstone retention window in days from `user_config`, falling back
546 + /// to the 30-day default (matching the M019 seed and macOS Trash convention) if
547 + /// the key is missing or unparseable.
548 + pub fn tombstone_retain_days(db: &Database) -> i64 {
549 + const DEFAULT_RETAIN_DAYS: i64 = 30;
550 + db.conn()
551 + .query_row(
552 + "SELECT value FROM user_config WHERE key = 'sample_tombstone_retain_days'",
553 + [],
554 + |row| row.get::<_, String>(0),
555 + )
556 + .ok()
557 + .and_then(|v| v.parse::<i64>().ok())
558 + .filter(|d| *d >= 0)
559 + .unwrap_or(DEFAULT_RETAIN_DAYS)
560 + }
561 +
424 562 /// Clamp an imported file's display name before it enters the DB and the sync
425 563 /// payload. The name is taken verbatim from disk, so bound its length (on a
426 564 /// UTF-8 char boundary) to a filesystem-scale limit. SQL binds it as a
@@ -812,6 +950,170 @@ mod tests {
812 950 path
813 951 }
814 952
953 + /// Give a sample one VFS placement, so CASCADE behaviour and placement
954 + /// preservation are observable.
955 + fn place_sample(db: &Database, hash: &str) {
956 + db.conn()
957 + .execute(
958 + "INSERT OR IGNORE INTO vfs (id, name, created_at, modified_at) \
959 + VALUES (1, 'Library', 0, 0)",
960 + [],
961 + )
962 + .unwrap();
963 + db.conn()
964 + .execute(
965 + "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) \
966 + VALUES (1, NULL, ?1, 'sample', ?1, 0)",
967 + [hash],
968 + )
969 + .unwrap();
970 + }
971 +
972 + fn count(db: &Database, sql: &str, hash: &str) -> i64 {
973 + db.conn().query_row(sql, [hash], |r| r.get(0)).unwrap()
974 + }
975 +
976 + #[test]
977 + fn tombstone_hides_sample_and_undelete_restores() {
978 + let (dir, db, store) = setup();
979 + let hash = store
980 + .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
981 + .unwrap();
982 +
983 + // Live: visible to the read path, absent from Trash.
984 + assert!(sample_extension(&db, &hash).is_ok());
985 + assert!(tombstoned_samples(&db).unwrap().is_empty());
986 +
987 + // Tombstone is a one-shot: the second call is a no-op.
988 + assert!(tombstone_sample(&db, &hash).unwrap());
989 + assert!(!tombstone_sample(&db, &hash).unwrap());
990 +
991 + // Hidden from the read path, surfaced in Trash with a timestamp.
992 + assert!(matches!(
993 + sample_extension(&db, &hash),
994 + Err(CoreError::SampleNotFound(_))
995 + ));
996 + let trash = tombstoned_samples(&db).unwrap();
997 + assert_eq!(trash.len(), 1);
998 + assert_eq!(trash[0].hash, hash);
999 + assert!(trash[0].deleted_at > 0);
1000 +
1001 + // Undelete restores it; a second undelete is a no-op.
1002 + assert!(undelete_sample(&db, &hash).unwrap());
1003 + assert!(!undelete_sample(&db, &hash).unwrap());
1004 + assert!(sample_extension(&db, &hash).is_ok());
1005 + assert!(tombstoned_samples(&db).unwrap().is_empty());
1006 + }
1007 +
1008 + #[test]
1009 + fn tombstone_preserves_placements_and_blob() {
1010 + let (dir, db, store) = setup();
1011 + let hash = store
1012 + .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
1013 + .unwrap();
1014 + place_sample(&db, &hash);
1015 +
1016 + assert!(tombstone_sample(&db, &hash).unwrap());
1017 +
1018 + // The whole point of soft delete: placements and the blob survive so the
1019 + // user can recover everything.
1020 + assert_eq!(
1021 + count(&db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &hash),
1022 + 1
1023 + );
1024 + assert!(store.exists(&hash, "wav").unwrap());
1025 + }
1026 +
1027 + #[test]
1028 + fn remove_purges_tombstoned_row_and_cascades() {
1029 + let (dir, db, store) = setup();
1030 + let hash = store
1031 + .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
1032 + .unwrap();
1033 + place_sample(&db, &hash);
1034 + assert!(tombstone_sample(&db, &hash).unwrap());
1035 +
1036 + // Permanent delete must work on a tombstoned row even though the
1037 + // filtered `sample_extension` would hide it — `remove` resolves the
1038 + // blob path unfiltered.
1039 + store.remove(&hash, &db).unwrap();
1040 +
1041 + assert!(!store.exists(&hash, "wav").unwrap());
1042 + assert_eq!(count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &hash), 0);
1043 + assert_eq!(
1044 + count(&db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &hash),
1045 + 0
1046 + );
1047 + }
1048 +
1049 + #[test]
1050 + fn sweep_hard_deletes_only_expired_tombstones() {
1051 + let (dir, db, store) = setup();
1052 + let live = store
1053 + .import(&create_test_file(&dir, "live.wav", b"live audio"), &db)
1054 + .unwrap();
1055 + let fresh = store
1056 + .import(&create_test_file(&dir, "fresh.wav", b"fresh audio"), &db)
1057 + .unwrap();
1058 + let old = store
1059 + .import(&create_test_file(&dir, "old.wav", b"old audio data"), &db)
1060 + .unwrap();
1061 + place_sample(&db, &old);
1062 +
1063 + // fresh: tombstoned just now. old: tombstoned beyond the 30-day window.
1064 + assert!(tombstone_sample(&db, &fresh).unwrap());
1065 + assert!(tombstone_sample(&db, &old).unwrap());
1066 + db.conn()
1067 + .execute(
1068 + "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2",
1069 + rusqlite::params![unix_now() - 31 * 86_400, old],
1070 + )
1071 + .unwrap();
1072 +
1073 + let removed = store.sweep_expired_tombstones(&db).unwrap();
1074 + assert_eq!(removed, 1);
1075 +
1076 + // old is gone (row, blob, and CASCADE'd placement); fresh + live stay.
1077 + assert!(!store.exists(&old, "wav").unwrap());
1078 + assert_eq!(count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &old), 0);
1079 + assert_eq!(
1080 + count(&db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &old),
1081 + 0
1082 + );
1083 + assert_eq!(tombstoned_samples(&db).unwrap().len(), 1);
1084 + assert!(sample_extension(&db, &live).is_ok());
1085 + assert!(store.exists(&fresh, "wav").unwrap());
1086 + }
1087 +
1088 + #[test]
1089 + fn sweep_respects_retain_days_config() {
1090 + let (dir, db, store) = setup();
1091 + let hash = store
1092 + .import(&create_test_file(&dir, "s.wav", b"some audio"), &db)
1093 + .unwrap();
1094 + assert!(tombstone_sample(&db, &hash).unwrap());
1095 + db.conn()
1096 + .execute(
1097 + "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2",
1098 + rusqlite::params![unix_now() - 5 * 86_400, hash],
1099 + )
1100 + .unwrap();
1101 +
1102 + // 5 days old, default 30-day window: not yet expired.
1103 + assert_eq!(tombstone_retain_days(&db), 30);
1104 + assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 0);
1105 +
1106 + // Shrink the window to 3 days: now it sweeps.
1107 + db.conn()
1108 + .execute(
1109 + "UPDATE user_config SET value = '3' WHERE key = 'sample_tombstone_retain_days'",
1110 + [],
1111 + )
1112 + .unwrap();
1113 + assert_eq!(tombstone_retain_days(&db), 3);
1114 + assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 1);
1115 + }
1116 +
815 1117 #[test]
816 1118 fn clamp_original_name_caps_length_on_char_boundary() {
817 1119 // Short names pass through untouched.
@@ -92,7 +92,7 @@ pub async fn download_missing_blobs(
92 92 }
93 93 };
94 94
95 - let data = match client.blob_download(&download_url).await {
95 + let data = match client.blob_download(hash, &download_url).await {
96 96 Ok(d) => d,
97 97 Err(e) => {
98 98 tracing::warn!("Failed to download blob {hash}: {e}");
@@ -209,7 +209,7 @@ pub async fn download_one_blob(
209 209 .await
210 210 .map_err(|e| SyncError::Client(e.to_string()))?;
211 211 let data = client
212 - .blob_download(&download_url)
212 + .blob_download(hash, &download_url)
213 213 .await
214 214 .map_err(|e| SyncError::Client(e.to_string()))?;
215 215
@@ -103,7 +103,7 @@ pub async fn upload_pending_blobs(
103 103 .map_err(SyncError::Io)?;
104 104
105 105 client
106 - .blob_upload(&resp.upload_url, data)
106 + .blob_upload(hash, &resp.upload_url, data)
107 107 .await
108 108 .map_err(|e| SyncError::Client(e.to_string()))?;
109 109
@@ -1,6 +1,6 @@
1 1 # Sample deletion — tombstone design (proposal)
2 2
3 - **Status:** proposal, 2026-06-02. Not yet implemented.
3 + **Status:** Phases 1-4 implemented (2026-06-19). Schema + read-path filter (Phase 1), tombstone/undelete/purge operations (Phase 2), the Trash section in Settings (Phase 3), and the startup sweep (Phase 4) are live. Phase 5 (pull-side "deleted on another device" notification) remains.
4 4
5 5 **Author:** Max / Claude (audit session)
6 6
@@ -153,15 +153,15 @@ The trigger bodies already serialize `NEW.*` columns by name, so the new `delete
153 153
154 154 ## Rollout phasing
155 155
156 - **Phase 1 — M019 + read-path filter** (1 session). Land the schema, add the `WHERE deleted_at IS NULL` filter across all read sites. Tombstones don't surface in UI yet; behavior change is invisible to users (everything is still NULL).
156 + **Phase 1 — M019 + read-path filter** (DONE). Schema landed, `WHERE deleted_at IS NULL` filter across all read sites. Tombstones don't surface in UI yet; behavior change is invisible to users (everything is still NULL).
157 157
158 - **Phase 2 — delete & undelete operations** (1 session). Wire the UPDATE path. Replace the current `SampleStore::remove` callers with `tombstone_sample` (a new method). The cleanup-orphans menu shipped 2026-06-02 keeps its local-only semantics but switches from hard delete to tombstone + immediate hard delete (since orphan = not referenced, no recovery value).
158 + **Phase 2 — delete & undelete operations** (DONE, 2026-06-19). `tombstone_sample`, `undelete_sample`, and the unfiltered-extension fix to `SampleStore::remove` (so hard-delete/purge works on tombstoned rows) all live in `core::store`. Note: the cleanup-orphans menu still hard-deletes immediately (orphan = no recovery value), as designed — it does not route through the tombstone path.
159 159
160 - **Phase 3 — Trash UI** (1 session). New Settings section listing tombstoned samples with undelete + delete-permanently affordances.
160 + **Phase 3 — Trash UI** (DONE, 2026-06-19). Trash section in Settings (`ui/settings_panel.rs::draw_trash_section`) lists tombstoned samples with Restore + Delete-permanently (behind a per-row confirm) affordances. Backed by `Backend::{list_tombstoned, undelete_sample, purge_sample}`.
161 161
162 - **Phase 4 — sweep** (1 session). Background task on app startup hard-deletes past-retention tombstones. Sync push of the resulting `samples` DELETE rows; receiving devices CASCADE their own data (which, by symmetry, has also been tombstoned for >= 30 days).
162 + **Phase 4 — sweep** (DONE, 2026-06-19). `SampleStore::sweep_expired_tombstones` runs once in `DirectBackend::new` at startup, hard-deleting past-retention tombstones. The `samples` DELETE is not sync-suppressed, so it propagates; receiving devices CASCADE their own data (which, by symmetry, has also been tombstoned for >= retention).
163 163
164 - **Phase 5 — sync notification** (1 session). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device — see Trash to recover."
164 + **Phase 5 — sync notification** (TODO). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device — see Trash to recover."
165 165
166 166 Phases 1-2 are required for correctness. Phases 3-5 are UX polish that can land in any order after Phase 2.
167 167