max / audiofiles
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"); |
| @@ -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. |