Skip to main content

max / audiofiles

db: M019 tombstone schema + read-path filter (Phase 1) First implementation phase of the multi-device sample-deletion design (docs/design-sample-deletion.md, F). Schema: - samples.deleted_at INTEGER (NULL = live, timestamp = tombstoned) - partial index idx_samples_deleted_at ON samples(deleted_at) WHERE deleted_at IS NOT NULL — stays tiny in steady state - user_config seed sample_tombstone_retain_days = 30 (matches OS Trash conventions; sync-replicated via the existing user_config trigger) - samples triggers recreated so deleted_at flows through wire JSON; json_object lists columns explicitly so the new field has to be added to every INSERT/UPDATE trigger body - Seed INSERT wrapped in applying_remote='1' suppression so the migration doesn't pollute sync_changelog with a spurious user_config row on every fresh install Read-path filter — every query that surfaces sample data to the user gains a WHERE deleted_at IS NULL (or s.deleted_at IS NULL for LEFT JOINs, where the NULL-on-no-match correctly passes directory nodes): - crates/audiofiles-core/src/store.rs: * remove_orphaned_samples — skip tombstoned (sweep will hard-delete) * query_sample_field (sample_extension, sample_original_name) * sample_source_path * check_loose_files_integrity * find_missing_loose_files * purge_missing_loose_files - crates/audiofiles-core/src/vfs.rs: * list_vfs_contents (parent_id IS) enriched query * by-hash variant - crates/audiofiles-core/src/search.rs: * search_in_folder * search_global - crates/audiofiles-core/src/export/mod.rs: * collect_items (preserve-tree path) * collect_items (flatten path) - crates/audiofiles-browser/src/cleanup.rs: * cleanup worker orphan query - crates/audiofiles-browser/src/backend/direct.rs: * blob file_size lookup Tests: - m019_tombstone_column_and_read_filter — column exists, partial index present, seed value matches, sample_extension hides tombstoned rows - migration_replay test bumped 18 -> 19 - three sync snapshot test counts bumped +1 for the new user_config seed row (correct behavior; old counts assumed no seed) Phase 1 is invisible to users (nothing sets deleted_at yet). Phase 2 wires the tombstone+undelete operations and replaces the existing hard-delete callers. 806 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 06:19 UTC
Commit: 0da053ac6c60bd3f74baab5d7e74fd500e4ea8cc
Parent: ed6636f
9 files changed, +181 insertions, -23 deletions
@@ -242,7 +242,7 @@ fn build_sample_info(
242 242 let file_size = db
243 243 .conn()
244 244 .query_row(
245 - "SELECT file_size FROM samples WHERE hash = ?1",
245 + "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL",
246 246 [&item.hash],
247 247 |row| row.get::<_, u64>(0),
248 248 )
@@ -146,7 +146,7 @@ fn remove_orphans(
146 146 "SELECT s.hash, s.file_extension, s.original_name
147 147 FROM samples s
148 148 LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash
149 - WHERE vn.id IS NULL",
149 + WHERE vn.id IS NULL AND s.deleted_at IS NULL",
150 150 ) {
151 151 Ok(mut stmt) => {
152 152 let rows = stmt
@@ -1041,6 +1041,82 @@ BEGIN
1041 1041 END;
1042 1042 "#;
1043 1043
1044 + /// M019 — soft-delete (tombstone) infrastructure for samples.
1045 + ///
1046 + /// Phase 1 of the multi-device sample-deletion design (see
1047 + /// `docs/design-sample-deletion.md`). This migration only lands the
1048 + /// schema and bumps the samples triggers to include the new column in
1049 + /// their wire-format JSON. No code path currently sets `deleted_at`, so
1050 + /// every existing read filter (`WHERE samples.deleted_at IS NULL`) is a
1051 + /// no-op until Phase 2 wires up the tombstone+undelete operations.
1052 + ///
1053 + /// Index is partial — only tombstoned rows are indexed, so the index
1054 + /// stays tiny in steady-state (most samples are live).
1055 + ///
1056 + /// `sample_tombstone_retain_days` defaults to 30 (matches OS Trash
1057 + /// conventions). User-configurable via the existing user_config sync
1058 + /// trigger; the value syncs across devices.
1059 + const MIGRATION_019: &str = r#"
1060 + ALTER TABLE samples ADD COLUMN deleted_at INTEGER;
1061 + CREATE INDEX IF NOT EXISTS idx_samples_deleted_at
1062 + ON samples(deleted_at) WHERE deleted_at IS NOT NULL;
1063 +
1064 + -- Suppress the user_config sync trigger for the duration of this seed
1065 + -- INSERT — otherwise the migration would push a spurious row into
1066 + -- sync_changelog on every fresh install. The trigger's WHEN clause
1067 + -- short-circuits while applying_remote = '1'. Both flips run inside
1068 + -- the migration's transaction, so a crash mid-migration rolls back the
1069 + -- flag-set along with everything else.
1070 + UPDATE sync_state SET value = '1' WHERE key = 'applying_remote';
1071 + INSERT OR IGNORE INTO user_config (key, value)
1072 + VALUES ('sample_tombstone_retain_days', '30');
1073 + UPDATE sync_state SET value = '0' WHERE key = 'applying_remote';
1074 +
1075 + -- Re-emit samples triggers so deleted_at flows through the wire JSON.
1076 + -- Existing INSERT/UPDATE bodies list columns explicitly; the new column
1077 + -- needs to be added to the json_object call (it doesn't pick up
1078 + -- automatically). DELETE trigger needs the column too so the receiving
1079 + -- device's apply_upsert sees the tombstone state on a re-INSERT path.
1080 + DROP TRIGGER IF EXISTS sync_samples_insert;
1081 + DROP TRIGGER IF EXISTS sync_samples_update;
1082 + DROP TRIGGER IF EXISTS sync_samples_delete;
1083 +
1084 + CREATE TRIGGER sync_samples_insert AFTER INSERT ON samples
1085 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1086 + BEGIN
1087 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1088 + VALUES ('samples', 'INSERT',
1089 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.hash),
1090 + json_object('hash', NEW.hash, 'original_name', NEW.original_name,
1091 + 'file_extension', NEW.file_extension, 'file_size', NEW.file_size,
1092 + 'import_date', NEW.import_date, 'last_modified', NEW.last_modified,
1093 + 'duration', NEW.duration, 'cloud_only', NEW.cloud_only,
1094 + 'source_path', NEW.source_path, 'deleted_at', NEW.deleted_at));
1095 + END;
1096 +
1097 + CREATE TRIGGER sync_samples_update AFTER UPDATE ON samples
1098 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1099 + BEGIN
1100 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1101 + VALUES ('samples', 'UPDATE',
1102 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), NEW.hash),
1103 + json_object('hash', NEW.hash, 'original_name', NEW.original_name,
1104 + 'file_extension', NEW.file_extension, 'file_size', NEW.file_size,
1105 + 'import_date', NEW.import_date, 'last_modified', NEW.last_modified,
1106 + 'duration', NEW.duration, 'cloud_only', NEW.cloud_only,
1107 + 'source_path', NEW.source_path, 'deleted_at', NEW.deleted_at));
1108 + END;
1109 +
1110 + CREATE TRIGGER sync_samples_delete AFTER DELETE ON samples
1111 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1112 + BEGIN
1113 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1114 + VALUES ('samples', 'DELETE',
1115 + hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), OLD.hash),
1116 + json_object('hash', OLD.hash));
1117 + END;
1118 + "#;
1119 +
1044 1120 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1045 1121 /// function on the given connection. Used by the M018 sync triggers so the
1046 1122 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1139,6 +1215,7 @@ impl Database {
1139 1215 MIGRATION_016,
1140 1216 MIGRATION_017,
1141 1217 MIGRATION_018,
1218 + MIGRATION_019,
1142 1219 ];
1143 1220
1144 1221 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -1316,7 +1393,7 @@ mod tests {
1316 1393 .conn()
1317 1394 .query_row("PRAGMA user_version", [], |row| row.get(0))
1318 1395 .unwrap();
1319 - assert_eq!(version, 18);
1396 + assert_eq!(version, 19);
1320 1397 }
1321 1398
1322 1399 #[test]
@@ -1327,7 +1404,7 @@ mod tests {
1327 1404 .conn()
1328 1405 .query_row("PRAGMA user_version", [], |row| row.get(0))
1329 1406 .unwrap();
1330 - assert_eq!(version, 18);
1407 + assert_eq!(version, 19);
1331 1408 }
1332 1409
1333 1410 /// Open a fresh file-backed DB, close, reopen. The second open re-enters
@@ -1346,7 +1423,7 @@ mod tests {
1346 1423 .conn()
1347 1424 .query_row("PRAGMA user_version", [], |row| row.get(0))
1348 1425 .unwrap();
1349 - assert_eq!(version, 18);
1426 + assert_eq!(version, 19);
1350 1427 }
1351 1428
1352 1429 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -1390,7 +1467,7 @@ mod tests {
1390 1467 .conn()
1391 1468 .query_row("PRAGMA user_version", [], |row| row.get(0))
1392 1469 .unwrap();
1393 - assert_eq!(version, 18);
1470 + assert_eq!(version, 19);
1394 1471 }
1395 1472
1396 1473 /// M018 contract: the `sync_changelog.row_id` for sensitive tables must
@@ -1494,6 +1571,76 @@ mod tests {
1494 1571 assert_eq!(parsed["tag"], "kick");
1495 1572 }
1496 1573
1574 + /// M019 contract: `samples.deleted_at` column exists, the partial
1575 + /// index is in place, and the read-path filter actually hides
1576 + /// tombstoned rows from `sample_extension` (and by extension every
1577 + /// other query that uses the `query_sample_field` helper).
1578 + ///
1579 + /// This test is the regression gate that proves Phase 1 of the
1580 + /// tombstone design (docs/design-sample-deletion.md) lands the
1581 + /// promised infrastructure. Phase 2 will wire the UPDATE path that
1582 + /// sets deleted_at; today nothing in app code sets it, so every
1583 + /// query continues to return all rows in practice — but tests can
1584 + /// set it directly and observe the filter working.
1585 + #[test]
1586 + fn m019_tombstone_column_and_read_filter() {
1587 + let db = Database::open_in_memory().unwrap();
1588 + let conn = db.conn();
1589 +
1590 + // Column exists with default NULL.
1591 + conn.execute(
1592 + "INSERT INTO samples (hash, original_name, file_extension, file_size, \
1593 + import_date, last_modified) VALUES \
1594 + ('live', 'k.wav', 'wav', 1, 0, 0)",
1595 + [],
1596 + )
1597 + .unwrap();
1598 + conn.execute(
1599 + "INSERT INTO samples (hash, original_name, file_extension, file_size, \
1600 + import_date, last_modified) VALUES \
1601 + ('tomb', 't.wav', 'wav', 1, 0, 0)",
1602 + [],
1603 + )
1604 + .unwrap();
1605 + conn.execute(
1606 + "UPDATE samples SET deleted_at = 1700000000 WHERE hash = 'tomb'",
1607 + [],
1608 + )
1609 + .unwrap();
1610 +
1611 + // sample_extension reads via query_sample_field, which now filters
1612 + // out tombstoned rows.
1613 + let live_ext = crate::store::sample_extension(&db, "live").unwrap();
1614 + assert_eq!(live_ext, "wav");
1615 +
1616 + let tomb_ext = crate::store::sample_extension(&db, "tomb");
1617 + assert!(
1618 + matches!(tomb_ext, Err(crate::error::CoreError::SampleNotFound(_))),
1619 + "tombstoned sample should be hidden from sample_extension; got {tomb_ext:?}"
1620 + );
1621 +
1622 + // Default retain-days seed is present.
1623 + let retain: String = conn
1624 + .query_row(
1625 + "SELECT value FROM user_config WHERE key = 'sample_tombstone_retain_days'",
1626 + [],
1627 + |r| r.get(0),
1628 + )
1629 + .unwrap();
1630 + assert_eq!(retain, "30");
1631 +
1632 + // Partial index exists.
1633 + let idx_count: i64 = conn
1634 + .query_row(
1635 + "SELECT COUNT(*) FROM sqlite_master \
1636 + WHERE type = 'index' AND name = 'idx_samples_deleted_at'",
1637 + [],
1638 + |r| r.get(0),
1639 + )
1640 + .unwrap();
1641 + assert_eq!(idx_count, 1);
1642 + }
1643 +
1497 1644 /// Recovery branch contract: when the non-ALTER batch fails for a
1498 1645 /// reason OTHER than "already exists", `migrate()` must roll back and
1499 1646 /// surface the error, NOT bump `user_version` past the failed
@@ -1536,7 +1683,7 @@ mod tests {
1536 1683 let initial_version: i32 = conn
1537 1684 .query_row("PRAGMA user_version", [], |row| row.get(0))
1538 1685 .unwrap();
1539 - assert_eq!(initial_version, 18);
1686 + assert_eq!(initial_version, 19);
1540 1687
1541 1688 let batch = format!(
1542 1689 "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;",
@@ -1599,7 +1746,7 @@ mod tests {
1599 1746 .conn()
1600 1747 .query_row("PRAGMA user_version", [], |row| row.get(0))
1601 1748 .unwrap();
1602 - assert_eq!(version, 18);
1749 + assert_eq!(version, 19);
1603 1750 }
1604 1751
1605 1752 #[test]
@@ -136,7 +136,7 @@ pub fn collect_export_items(
136 136 JOIN vfs_nodes n ON n.id = t.id
137 137 LEFT JOIN samples s ON n.sample_hash = s.hash
138 138 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
139 - WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL
139 + WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL AND s.deleted_at IS NULL
140 140 ORDER BY t.path"
141 141 } else {
142 142 "WITH RECURSIVE tree(id, path) AS (
@@ -155,7 +155,7 @@ pub fn collect_export_items(
155 155 JOIN vfs_nodes n ON n.id = t.id
156 156 LEFT JOIN samples s ON n.sample_hash = s.hash
157 157 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
158 - WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL
158 + WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL AND s.deleted_at IS NULL
159 159 ORDER BY t.path"
160 160 };
161 161
@@ -153,7 +153,7 @@ pub fn search_in_folder(
153 153 FROM vfs_nodes n
154 154 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
155 155 LEFT JOIN samples s ON n.sample_hash = s.hash
156 - WHERE n.vfs_id = ?1 AND n.parent_id IS ?2",
156 + WHERE n.vfs_id = ?1 AND n.parent_id IS ?2 AND s.deleted_at IS NULL",
157 157 );
158 158 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
159 159 params.push(Box::new(vfs_id));
@@ -182,7 +182,7 @@ pub fn search_global(
182 182 FROM vfs_nodes n
183 183 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
184 184 LEFT JOIN samples s ON n.sample_hash = s.hash
185 - WHERE 1=1",
185 + WHERE s.deleted_at IS NULL",
186 186 );
187 187 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
188 188
@@ -215,11 +215,15 @@ impl SampleStore {
215 215 /// file blob is removed from disk.
216 216 #[instrument(skip_all)]
217 217 pub fn remove_orphaned_samples(&self, db: &Database) -> Result<usize> {
218 + // Skip tombstoned rows — the eventual hard-delete sweep
219 + // (docs/design-sample-deletion.md Phase 4) will get them when the
220 + // retention window expires. Double-processing here would race the
221 + // sweep and pre-emptively destroy still-recoverable data.
218 222 let mut stmt = db.conn().prepare(
219 223 "SELECT s.hash, s.file_extension
220 224 FROM samples s
221 225 LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash
222 - WHERE vn.id IS NULL",
226 + WHERE vn.id IS NULL AND s.deleted_at IS NULL",
223 227 )?;
224 228 let orphans: Vec<(String, String)> = stmt
225 229 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
@@ -292,7 +296,7 @@ fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String>
292 296 )));
293 297 }
294 298
295 - let sql = format!("SELECT {field} FROM samples WHERE hash = ?1");
299 + let sql = format!("SELECT {field} FROM samples WHERE hash = ?1 AND deleted_at IS NULL");
296 300 db.conn()
297 301 .query_row(&sql, [hash], |row| row.get(0))
298 302 .map_err(|e| match e {
@@ -321,7 +325,7 @@ pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> {
321 325 pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> {
322 326 db.conn()
323 327 .query_row(
324 - "SELECT source_path FROM samples WHERE hash = ?1",
328 + "SELECT source_path FROM samples WHERE hash = ?1 AND deleted_at IS NULL",
325 329 [hash],
326 330 |row| row.get(0),
327 331 )
@@ -404,7 +408,7 @@ pub fn relocate_sample(
404 408 /// exists vs. does not exist on disk.
405 409 pub fn check_loose_files_integrity(db: &Database) -> Result<(usize, usize)> {
406 410 let mut stmt = db.conn().prepare(
407 - "SELECT source_path FROM samples WHERE source_path IS NOT NULL",
411 + "SELECT source_path FROM samples WHERE source_path IS NOT NULL AND deleted_at IS NULL",
408 412 )?;
409 413 let paths: Vec<String> = stmt
410 414 .query_map([], |row| row.get(0))?
@@ -441,7 +445,7 @@ pub fn relocate_missing_loose_files(
441 445 // the recorded file_size (used for cheap pre-filter before re-hashing).
442 446 let mut stmt = db.conn().prepare(
443 447 "SELECT hash, source_path, file_size FROM samples \
444 - WHERE source_path IS NOT NULL",
448 + WHERE source_path IS NOT NULL AND deleted_at IS NULL",
445 449 )?;
446 450 let rows: Vec<(String, String, i64)> = stmt
447 451 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
@@ -545,7 +549,7 @@ pub fn relocate_missing_loose_files(
545 549 /// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc.
546 550 pub fn purge_missing_loose_files(db: &Database) -> Result<usize> {
547 551 let mut stmt = db.conn().prepare(
548 - "SELECT hash, source_path FROM samples WHERE source_path IS NOT NULL",
552 + "SELECT hash, source_path FROM samples WHERE source_path IS NOT NULL AND deleted_at IS NULL",
549 553 )?;
550 554 let rows: Vec<(String, String)> = stmt
551 555 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
@@ -449,6 +449,7 @@ pub fn list_children_enriched(
449 449 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
450 450 LEFT JOIN samples s ON n.sample_hash = s.hash
451 451 WHERE n.vfs_id = ?1 AND n.parent_id IS ?2
452 + AND s.deleted_at IS NULL
452 453 ORDER BY n.node_type ASC, n.name ASC",
453 454 )?;
454 455 let rows = stmt.query_map(rusqlite::params![vfs_id, parent_id], map_enriched_row)?;
@@ -603,6 +604,7 @@ pub fn find_nodes_by_hashes(
603 604 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
604 605 LEFT JOIN samples s ON n.sample_hash = s.hash
605 606 WHERE n.vfs_id = ?1 AND n.sample_hash IN ({})
607 + AND s.deleted_at IS NULL
606 608 GROUP BY n.sample_hash",
607 609 placeholders.join(", ")
608 610 );
@@ -584,7 +584,9 @@ mod tests {
584 584 clear_changelog(conn);
585 585
586 586 let total = create_initial_snapshot(conn).unwrap();
587 - assert_eq!(total, 3); // 2 samples + 1 vfs
587 + // 2 samples + 1 vfs + 1 user_config seed
588 + // (`sample_tombstone_retain_days = 30`, seeded by M019).
589 + assert_eq!(total, 4);
588 590 }
589 591
590 592 #[test]
@@ -854,8 +856,9 @@ mod tests {
854 856 clear_changelog(conn);
855 857
856 858 let total = create_initial_snapshot(conn).unwrap();
857 - // 2 samples + 2 tags + 1 collection + 1 collection_member = 6
858 - assert_eq!(total, 6);
859 + // 2 samples + 2 tags + 1 collection + 1 collection_member
860 + // + 1 user_config seed (sample_tombstone_retain_days, from M019) = 7
861 + assert_eq!(total, 7);
859 862
860 863 assert_eq!(changelog_count(conn, Some("samples"), Some("INSERT")), 2);
861 864 assert_eq!(changelog_count(conn, Some("tags"), Some("INSERT")), 2);
@@ -986,7 +989,8 @@ mod tests {
986 989 clear_changelog(conn);
987 990
988 991 let first = create_initial_snapshot(conn).unwrap();
989 - assert_eq!(first, 1);
992 + // 1 sample + 1 user_config seed (sample_tombstone_retain_days, M019)
993 + assert_eq!(first, 2);
990 994
991 995 // Add more data after snapshot (these go through normal triggers)
992 996 insert_sample(conn, "second", "two.wav", "wav");
M todo.md +2 -1
@@ -59,6 +59,7 @@ Launch shipped 2026-06-01 (see `/Users/max/Code/launchplan_final.md`). Post-laun
59 59 - Added `migration_replay_from_file_no_op` and `migration_replay_from_version_two_against_full_schema` tests. The replay test rolls `PRAGMA user_version=2` against a populated schema and re-runs every migration from M003 onward; future non-idempotent CREATEs will fail this test loudly.
60 60 - Documented why M001 (initial schema) and M002 (table-rebuild dance with `DROP TABLE tags; ALTER tags_v2 RENAME TO tags`) are inherently one-shot and not replay-safe.
61 61 - **Recovery branch validator landed 2026-06-02.** Replaced the `tracing::warn!` + silent-bump path with fail-fast: on any non-ALTER recovery failure that isn't "already exists", roll back and surface the error. This immediately surfaced a real latent bug — M007 created sync triggers on `smart_folders`, which M015 drops, so post-M015 replay parsed-failed on M007. Fixed by removing the smart_folders triggers from M007 (M015's `DROP TRIGGER IF EXISTS` stays for already-installed DBs). M015 itself is now documented as inherently one-shot alongside M001/M002 (the backfill `SELECT FROM smart_folders` can't parse on replay against a populated post-M015 schema; SQLite has no conditional-execute). Replay test rolls `user_version` back to 15 (post-M015) rather than 2; the realistic recovery scenario is "re-apply the one migration that crashed", not "re-apply every migration from scratch". Added `migrate_recovery_branch_fails_fast_on_non_alter_error` and `migrate_recovery_branch_tolerates_already_exists`.
62 - - **Design landed 2026-06-02:** sample-deletion semantics for multi-device sync are designed in `docs/design-sample-deletion.md` (F: tombstone column on `samples`, 30-day retention, sync-replicated, Trash UI). Single-device delete was already correct (placement-only); the load-bearing concern was sync-pull `DELETE samples` cascading globally on the receiving device. Phasing in the doc (M019 + read-path filter → delete+undelete ops → Trash UI → sweep → notification). Five phases, single session each. Implementation deferred to next sessions.
62 + - **Design landed 2026-06-02:** sample-deletion semantics for multi-device sync are designed in `docs/design-sample-deletion.md` (F: tombstone column on `samples`, 30-day retention, sync-replicated, Trash UI). Single-device delete was already correct (placement-only); the load-bearing concern was sync-pull `DELETE samples` cascading globally on the receiving device. Phasing in the doc (M019 + read-path filter → delete+undelete ops → Trash UI → sweep → notification).
63 + - **Phase 1 landed 2026-06-02:** M019 adds `samples.deleted_at INTEGER`, a partial `idx_samples_deleted_at` index, and seeds `user_config.sample_tombstone_retain_days=30` (suppressed from sync_changelog during migration). Samples triggers recreated to flow `deleted_at` through the wire JSON. Read-path filter (`WHERE deleted_at IS NULL` or `s.deleted_at IS NULL` on joins) applied across `store.rs` (orphan query, field lookup, source_path lookup, loose-files integrity, loose-files file_size, purge_missing_loose_files), `vfs.rs` (enriched contents + by-hash queries), `search.rs` (search_in_folder + search_global), `export/mod.rs` (collect items, both paths), `cleanup.rs` (orphan worker), `backend/direct.rs` (blob file_size lookup). `m019_tombstone_column_and_read_filter` test proves column + index + seed + read filter. Three sync snapshot test counts bumped (+1 for the user_config seed). Phase 2 (delete/undelete ops) next.
63 64 - **Local-only orphan cleanup shipped 2026-06-02:** Settings → Storage → "Cleanup orphans" button calls a new `Backend::cleanup_orphans_local` that wraps `remove_orphaned_samples` in `applying_remote='1'` trigger suppression. Forward-compatible with the tombstone design (Phase 2 will replace the underlying call with `tombstone_sample` + immediate hard-delete for orphans).
64 65 - **Deferred, historical:** M015 backfill INSERT into `collections` fires `sync_collections_insert` from M007 → emits spurious `sync_changelog` rows on devices that ran M015. Has already shipped. Future migrations should wrap data backfills with `UPDATE sync_state SET value='1' WHERE key='applying_remote'` and reset on commit.