max / audiofiles
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 |