agent: code-atomiser-fix — soft-delete transcripts with audio cleanup (Rev-2, Rev-3)

Two interlocking reversibility kills, fixed as one bundle:

Rev-2 (hard-DELETE transcripts, no trash) — delete_transcript previously
issued DELETE FROM transcripts WHERE id = ?, so a single click on the
History "Clear All" / "Confirm" button erased months of dictation with no
trash, no export, no undo. The new contract:
  * delete_transcript UPDATEs deleted_at = datetime('now') and best-effort
    removes the audio file. Idempotent on repeat call.
  * Migration v16 adds `deleted_at TEXT` plus a partial index over the trash
    rows so the purge query stays cheap on long-running databases.
  * get_transcript, list_transcripts_paged, count_transcripts, and
    search_transcripts all filter `deleted_at IS NULL` so trash rows are
    invisible to the regular history view but kept for restore.
  * New list_trashed_transcripts and restore_transcript power the inverse
    trash view; row order is most-recently-deleted first.
  * New purge_deleted_transcripts(older_than_days) hard-removes trash
    older than the retention window. Wired into the Tauri setup hook at
    30-day retention; best-effort, never blocks startup.

Rev-3 (orphan WAV files on transcript delete) — same delete_transcript
function. Audio file at audio_path is now best-effort removed when the
soft-delete actually flips a row; NotFound is the expected case for
repeat deletes and is not logged. purge_deleted_transcripts also retries
audio removal as belt-and-braces.

Adds 4 regression tests: delete_transcript_soft_deletes,
delete_transcript_removes_audio_file, list_transcripts_excludes_soft_deleted
(also covers restore_transcript), and purge_deleted_transcripts_hard_deletes_old.
Plus migration_v16_adds_deleted_at_column_and_index already in place.

Frontend UI (HistoryPage clearAll type-the-word modal + View trash path)
deferred to a follow-up commit; the backend contract is complete and the
existing arm-confirm flow now soft-deletes instead of hard-deleting, so
the user-data-loss class is closed for the live release path. Rev-4
(SettingsPage deleteProfile routing) also deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 17:39:03 +01:00
parent 1068ad9c7d
commit 15b74db747
4 changed files with 492 additions and 20 deletions

View File

@@ -477,6 +477,36 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
ON transcripts(profile_id, created_at DESC);
"#,
),
(
16,
"transcripts: soft-delete column for trash + audio cleanup (Rev-2, Rev-3)",
r#"
-- Atomiser fix 2026-05-12 — Rev-2 (hard-DELETE w/ no trash) and
-- Rev-3 (orphan WAV files on transcript delete) both fanned out
-- from the same DELETE-based delete path. The new contract:
--
-- * `delete_transcript` UPDATEs deleted_at = datetime('now')
-- and best-effort removes the audio file at audio_path.
-- The DB row is preserved so the user can restore.
-- * `list_transcripts*`, `search_transcripts`, `count_transcripts`
-- filter `deleted_at IS NULL`.
-- * `purge_deleted_transcripts(older_than_days)` does the
-- actual hard DELETE on rows past the retention window,
-- scheduled once at startup. FK CASCADE on segments still
-- fires at purge time.
--
-- Pre-existing rows default to NULL (not deleted). The column
-- is plain TEXT (ISO-8601 datetime), nullable, no CHECK — we
-- only ever compare against datetime('now', '-N days') and
-- IS NULL / IS NOT NULL.
ALTER TABLE transcripts ADD COLUMN deleted_at TEXT;
-- Partial index over the trash so the purge query stays cheap
-- even on databases with months of soft-deleted rows.
CREATE INDEX IF NOT EXISTS idx_transcripts_deleted_at
ON transcripts(deleted_at) WHERE deleted_at IS NOT NULL;
"#,
),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -642,7 +672,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 15);
assert_eq!(count, 16);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -661,7 +691,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 15);
assert_eq!(count, 16);
}
#[tokio::test]
@@ -1199,4 +1229,69 @@ mod tests {
"planner should use the composite index, got plan: {plan:?}",
);
}
#[tokio::test]
async fn migration_v16_adds_deleted_at_column_and_index() {
// Rev-2 / Rev-3 atomiser fix (2026-05-12). Verify the soft-delete
// column is present, nullable, defaulting to NULL on pre-existing
// rows so the migration doesn't accidentally mark old transcripts
// as deleted.
let pool = fk_test_pool().await;
run_migrations_up_to(&pool, 15).await.expect("migrate to v15");
// Seed a pre-v16 row to verify backfill preserves NULL.
sqlx::query(
"INSERT INTO transcripts (
id, text, source, profile_id, title, audio_path, duration,
engine, model_id, inference_ms, sample_rate, audio_channels,
format_mode, remove_fillers, british_english, anti_hallucination,
created_at, starred, manual_tags, template, language,
segments_json, llm_tags
) VALUES (
'pre-v16', 'pre-existing body', 'microphone', ?, 'Pre-V16',
NULL, 1.0, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, 0,
datetime('now'), 0, '', '', '', '', ''
)",
)
.bind(crate::DEFAULT_PROFILE_ID)
.execute(&pool)
.await
.expect("seed pre-v16 row");
run_migrations(&pool).await.expect("migrate to v16");
let info = sqlx::query("PRAGMA table_info(transcripts)")
.fetch_all(&pool)
.await
.expect("pragma");
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
assert!(
names.contains(&"deleted_at".to_string()),
"transcripts must have deleted_at after v16; got {names:?}",
);
// Pre-existing rows must default to NULL — emphatically NOT
// pre-soft-deleted.
let deleted_at: Option<String> =
sqlx::query_scalar("SELECT deleted_at FROM transcripts WHERE id = 'pre-v16'")
.fetch_one(&pool)
.await
.expect("read deleted_at");
assert!(
deleted_at.is_none(),
"pre-v16 rows must have deleted_at = NULL after migration, got {deleted_at:?}",
);
// Partial index exists.
let index_names: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'transcripts'",
)
.fetch_all(&pool)
.await
.expect("read indexes");
assert!(
index_names.iter().any(|n| n == "idx_transcripts_deleted_at"),
"expected idx_transcripts_deleted_at, got {index_names:?}",
);
}
}