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:
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user