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

@@ -129,9 +129,13 @@ pub async fn insert_transcript(
Ok(())
}
/// Fetch a transcript by id, EXCLUDING soft-deleted (deleted_at IS NOT NULL)
/// rows. Soft-deleted rows are still in the table — they are scoped to the
/// trash view via `list_trashed_transcripts` / `restore_transcript` — but the
/// regular read path treats them as gone. Rev-2 (atomiser 2026-05-12).
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
let row = sqlx::query(
"SELECT 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 FROM transcripts WHERE id = ?",
"SELECT 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 FROM transcripts WHERE id = ? AND deleted_at IS NULL",
)
.bind(id)
.fetch_optional(pool)
@@ -155,8 +159,11 @@ pub async fn list_transcripts_paged(
limit: i64,
offset: i64,
) -> Result<Vec<TranscriptRow>> {
// `deleted_at IS NULL` filter is the Rev-2 soft-delete contract: rows
// in the trash are kept in the table for restore, but the regular list
// path treats them as gone.
let rows = sqlx::query(
"SELECT 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 FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
"SELECT 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 FROM transcripts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?",
)
.bind(limit)
.bind(offset)
@@ -171,14 +178,18 @@ pub async fn list_transcripts_paged(
}
/// Total count of transcripts. Useful for displaying "showing 50 of 312" in the UI.
/// Excludes soft-deleted (deleted_at IS NOT NULL) rows — they only count
/// toward the trash view.
pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts")
.fetch_one(pool)
.await
.map_err(|source| Error::Query {
operation: "count_transcripts".into(),
source,
})?;
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM transcripts WHERE deleted_at IS NULL",
)
.fetch_one(pool)
.await
.map_err(|source| Error::Query {
operation: "count_transcripts".into(),
source,
})?;
Ok(n)
}
@@ -293,13 +304,202 @@ pub async fn update_transcript_meta(
})
}
/// Soft-delete a transcript and best-effort remove its audio file.
///
/// Rev-2 / Rev-3 reversibility kill (atomiser 2026-05-12). The previous
/// implementation issued a hard `DELETE FROM transcripts WHERE id = ?`
/// and never touched the WAV file at `audio_path`. Two failure modes
/// fanned out from that:
///
/// * Rev-2: a single click on the History "Clear All" / "Confirm"
/// button immediately erased months of dictation with no trash,
/// no export, no undo.
/// * Rev-3: even single-row deletes left the audio file on disk;
/// the recordings dir grew monotonically.
///
/// The new contract:
///
/// 1. Capture the existing row (so we still have `audio_path` even if
/// the soft-delete is observed by a parallel reader before we get
/// here).
/// 2. UPDATE deleted_at = datetime('now'). FTS triggers fire on UPDATE
/// and re-index the row, but `search_transcripts` / `list_transcripts`
/// filter `deleted_at IS NULL`, so the trash rows don't surface.
/// 3. Best-effort `tokio::fs::remove_file(audio_path)`. A failure
/// (file already gone, permission denied) is logged but does NOT
/// propagate — the DB soft-delete has already succeeded, and
/// `purge_deleted_transcripts` will retry the removal later via
/// its own audio_path lookup before hard-deleting the row.
///
/// Returns `Ok(())` even when no row matched the id (idempotent).
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("DELETE FROM transcripts WHERE id = ?")
// Capture audio_path FIRST. We deliberately bypass `get_transcript`
// (which filters deleted_at IS NULL) so a double-delete still finds
// the row and the second call is a no-op cleanup rather than an
// error.
let audio_path: Option<String> = sqlx::query_scalar(
"SELECT audio_path FROM transcripts WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|source| Error::Query {
operation: "delete_transcript".into(),
source,
})?
.flatten();
let res = sqlx::query(
"UPDATE transcripts SET deleted_at = datetime('now') \
WHERE id = ? AND deleted_at IS NULL",
)
.bind(id)
.execute(pool)
.await
.map_err(|source| Error::Query {
operation: "delete_transcript".into(),
source,
})?;
// Best-effort audio cleanup. Only attempt removal when the
// soft-delete actually flipped a row from live -> trashed; on a
// repeat soft-delete the file is already gone (or never existed)
// and there's nothing to do.
if res.rows_affected() > 0 {
if let Some(path) = audio_path.as_deref() {
if let Err(err) = tokio::fs::remove_file(path).await {
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!(
target: "lumotia_storage",
"delete_transcript: failed to remove audio file at {path}: {err}"
);
}
}
}
}
Ok(())
}
/// Hard-delete soft-deleted rows older than `older_than_days`. Intended to
/// run once per startup (or on a daily cron). Returns the number of rows
/// removed.
///
/// FK cascades: `segments` ON DELETE CASCADE (v1) fires; tasks with
/// `source_transcript_id` ON DELETE SET NULL (v8) is preserved.
///
/// Audio files are also best-effort removed here in case the original
/// soft-delete failed at the filesystem layer (Rev-3 belt-and-braces).
pub async fn purge_deleted_transcripts(
pool: &SqlitePool,
older_than_days: i64,
) -> Result<u64> {
// Collect (id, audio_path) BEFORE the DELETE so we still have the
// paths to clean up after the row is gone.
let rows = sqlx::query(
"SELECT id, audio_path FROM transcripts \
WHERE deleted_at IS NOT NULL \
AND deleted_at < datetime('now', ?)",
)
.bind(format!("-{older_than_days} days"))
.fetch_all(pool)
.await
.map_err(|source| Error::Query {
operation: "purge_deleted_transcripts_select".into(),
source,
})?;
if rows.is_empty() {
return Ok(0);
}
let mut ids: Vec<String> = Vec::with_capacity(rows.len());
let mut audio_paths: Vec<String> = Vec::new();
for row in &rows {
let id: String = row.get("id");
let audio: Option<String> = row.get("audio_path");
ids.push(id);
if let Some(p) = audio {
audio_paths.push(p);
}
}
// Build the IN clause manually because sqlx 0.8 doesn't expand Vec
// bindings; we batch into chunks of 200 to stay well clear of
// SQLITE_MAX_VARIABLE_NUMBER (default 999).
let mut total: u64 = 0;
for chunk in ids.chunks(200) {
let placeholders = std::iter::repeat("?")
.take(chunk.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("DELETE FROM transcripts WHERE id IN ({placeholders})");
let mut q = sqlx::query(&sql);
for id in chunk {
q = q.bind(id);
}
let res = q.execute(pool).await.map_err(|source| Error::Query {
operation: "purge_deleted_transcripts_delete".into(),
source,
})?;
total += res.rows_affected();
}
// Best-effort audio cleanup. NotFound is the expected case for rows
// whose audio was already removed at soft-delete time.
for path in &audio_paths {
if let Err(err) = tokio::fs::remove_file(path).await {
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!(
target: "lumotia_storage",
"purge_deleted_transcripts: failed to remove audio file at {path}: {err}"
);
}
}
}
Ok(total)
}
/// List soft-deleted transcripts (the "trash" view), most-recently-deleted
/// first. The contract mirrors `list_transcripts_paged` but filters
/// `deleted_at IS NOT NULL` so the regular history view and the trash view
/// are exact complements.
pub async fn list_trashed_transcripts(
pool: &SqlitePool,
limit: i64,
offset: i64,
) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query(
"SELECT 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 \
FROM transcripts \
WHERE deleted_at IS NOT NULL \
ORDER BY deleted_at DESC LIMIT ? OFFSET ?",
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|source| Error::Query {
operation: "list_trashed_transcripts".into(),
source,
})?;
Ok(rows.iter().map(transcript_row_from).collect())
}
/// Restore a soft-deleted transcript by clearing `deleted_at`. Idempotent:
/// restoring a live row is a no-op. Note that the audio file at
/// `audio_path` may already have been removed by `delete_transcript`'s
/// best-effort filesystem cleanup; restoring the row recovers the text and
/// metadata but the audio may still be missing.
pub async fn restore_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("UPDATE transcripts SET deleted_at = NULL WHERE id = ?")
.bind(id)
.execute(pool)
.await
.map_err(|source| Error::Query {
operation: "delete_transcript".into(),
operation: "restore_transcript".into(),
source,
})?;
Ok(())
@@ -314,11 +514,15 @@ pub async fn search_transcripts(
query: &str,
limit: i64,
) -> Result<Vec<TranscriptRow>> {
// The FTS triggers from migration v2 keep `transcripts_fts` in sync
// for every INSERT/UPDATE/DELETE — including the soft-delete UPDATE,
// which keeps the row in transcripts_fts. The `t.deleted_at IS NULL`
// filter on the JOIN keeps trashed rows out of search results.
let rows = sqlx::query(
"SELECT t.id, t.text, t.source, t.profile_id, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json, t.llm_tags \
FROM transcripts t \
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
WHERE transcripts_fts MATCH ? \
WHERE transcripts_fts MATCH ? AND t.deleted_at IS NULL \
ORDER BY fts.rank LIMIT ?",
)
.bind(query)
@@ -2862,4 +3066,151 @@ mod tests {
assert_eq!(first, (1, 0));
assert_eq!(second, (0, 0));
}
fn minimal_transcript(id: &'static str, audio_path: Option<&'static str>) -> InsertTranscriptParams<'static> {
InsertTranscriptParams {
id,
text: "soft-delete fixture",
source: "microphone",
profile_id: crate::DEFAULT_PROFILE_ID,
title: None,
audio_path,
duration: 1.0,
engine: None,
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: true,
anti_hallucination: false,
}
}
#[tokio::test]
async fn delete_transcript_soft_deletes() {
// Rev-2 contract: delete_transcript flips `deleted_at`, does not
// remove the row. A direct SELECT bypassing the deleted_at filter
// must still find the row.
let pool = test_pool().await;
insert_transcript(&pool, &minimal_transcript("t-soft", None))
.await
.unwrap();
delete_transcript(&pool, "t-soft").await.unwrap();
let deleted_at: Option<String> = sqlx::query_scalar(
"SELECT deleted_at FROM transcripts WHERE id = ?",
)
.bind("t-soft")
.fetch_one(&pool)
.await
.unwrap();
assert!(deleted_at.is_some(), "row should still exist with deleted_at set");
}
#[tokio::test]
async fn delete_transcript_removes_audio_file() {
// Rev-3 contract: best-effort fs::remove_file fires when the
// soft-delete actually flipped a row. A repeat delete is a
// no-op and does NOT fail when the file is already gone.
let pool = test_pool().await;
let tmp = std::env::temp_dir().join(format!(
"lumotia-test-{}.wav",
std::process::id()
));
std::fs::write(&tmp, b"fake wav").unwrap();
let path_owned = tmp.to_string_lossy().to_string();
let path_static: &'static str = Box::leak(path_owned.into_boxed_str());
insert_transcript(&pool, &minimal_transcript("t-audio", Some(path_static)))
.await
.unwrap();
assert!(tmp.exists(), "fixture file should exist pre-delete");
delete_transcript(&pool, "t-audio").await.unwrap();
assert!(!tmp.exists(), "audio file should be removed by delete_transcript");
// Repeat delete must not surface an error even though both the
// soft-delete UPDATE is a no-op AND the audio file is already gone.
delete_transcript(&pool, "t-audio").await.unwrap();
}
#[tokio::test]
async fn list_transcripts_excludes_soft_deleted() {
// Rev-2 list contract: soft-deleted rows are invisible to the
// regular list path; the trash view is the inverse.
let pool = test_pool().await;
insert_transcript(&pool, &minimal_transcript("t-live", None))
.await
.unwrap();
insert_transcript(&pool, &minimal_transcript("t-trashed", None))
.await
.unwrap();
delete_transcript(&pool, "t-trashed").await.unwrap();
let live = list_transcripts(&pool, 100).await.unwrap();
assert_eq!(live.len(), 1);
assert_eq!(live[0].id, "t-live");
let trashed = list_trashed_transcripts(&pool, 100, 0).await.unwrap();
assert_eq!(trashed.len(), 1);
assert_eq!(trashed[0].id, "t-trashed");
let total = count_transcripts(&pool).await.unwrap();
assert_eq!(total, 1, "count_transcripts excludes the trash");
// Restore brings the row back into the live list.
restore_transcript(&pool, "t-trashed").await.unwrap();
let after_restore = list_transcripts(&pool, 100).await.unwrap();
assert_eq!(after_restore.len(), 2);
}
#[tokio::test]
async fn purge_deleted_transcripts_hard_deletes_old() {
// Retention contract: purge_deleted_transcripts hard-removes rows
// whose deleted_at is older than `older_than_days`. Newer trash
// rows are preserved.
let pool = test_pool().await;
insert_transcript(&pool, &minimal_transcript("t-old", None))
.await
.unwrap();
insert_transcript(&pool, &minimal_transcript("t-new", None))
.await
.unwrap();
delete_transcript(&pool, "t-old").await.unwrap();
delete_transcript(&pool, "t-new").await.unwrap();
// Backdate t-old past the 30-day window. t-new keeps "now".
sqlx::query(
"UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?",
)
.bind("t-old")
.execute(&pool)
.await
.unwrap();
let purged = purge_deleted_transcripts(&pool, 30).await.unwrap();
assert_eq!(purged, 1, "only t-old should be hard-deleted");
let old_exists: Option<String> = sqlx::query_scalar(
"SELECT id FROM transcripts WHERE id = ?",
)
.bind("t-old")
.fetch_optional(&pool)
.await
.unwrap();
assert!(old_exists.is_none(), "t-old should be hard-gone");
let new_exists: Option<String> = sqlx::query_scalar(
"SELECT id FROM transcripts WHERE id = ?",
)
.bind("t-new")
.fetch_optional(&pool)
.await
.unwrap();
assert!(new_exists.is_some(), "t-new still inside the retention window");
}
}

View File

@@ -16,11 +16,11 @@ pub use database::{
get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
migrate_legacy_setting_keys, prune_error_log, record_feedback, search_transcripts,
set_implementation_rule_enabled, set_setting, set_task_energy, uncomplete_task, update_profile,
update_task, update_transcript,
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
list_transcripts, list_transcripts_paged, list_trashed_transcripts, log_error,
mark_implementation_rule_fired, migrate_legacy_setting_keys, prune_error_log,
purge_deleted_transcripts, record_feedback, restore_transcript, search_transcripts,
set_implementation_rule_enabled, set_setting, set_task_energy, uncomplete_task,
update_profile, update_task, update_transcript, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
RecordFeedbackParams, TaskRow, TranscriptRow,
};

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:?}",
);
}
}

View File

@@ -25,13 +25,19 @@ use lumotia_core::types::EngineName;
use lumotia_llm::LlmEngine;
use lumotia_storage::{
database_path, get_setting, init as init_db, migrate_legacy_setting_keys, prune_error_log,
set_setting,
purge_deleted_transcripts, set_setting,
};
/// How long to retain `error_log` rows. Pruned once on startup.
/// 90 days is long enough to triage "this happened a few weeks ago"
/// reports and short enough that the table stays kilobyte-scale.
const ERROR_LOG_RETENTION_DAYS: i64 = 90;
/// How long soft-deleted transcripts live in the trash before
/// `purge_deleted_transcripts` hard-removes them on startup. 30 days
/// gives the user a comfortable undo window without keeping months of
/// abandoned audio on disk.
const TRANSCRIPT_TRASH_RETENTION_DAYS: i64 = 30;
use lumotia_transcription::LocalEngine;
static TRACING_INIT: Once = Once::new();
@@ -491,6 +497,26 @@ pub fn run() {
Err(e) => tracing::warn!(target: "lumotia_startup", error = %e, "error log prune failed"),
}
// Hard-delete soft-deleted transcripts past the retention
// window (Rev-2 atomiser fix). Best-effort — a purge failure
// means trash rows stick around an extra day, not a blocker.
let t_purge_trash = Instant::now();
match purge_deleted_transcripts(&db, TRANSCRIPT_TRASH_RETENTION_DAYS).await {
Ok(n) if n > 0 => tracing::info!(
target: "lumotia_startup",
rows_removed = n,
retention_days = TRANSCRIPT_TRASH_RETENTION_DAYS,
elapsed_ms = t_purge_trash.elapsed().as_millis(),
"transcript trash purge complete"
),
Ok(_) => {}
Err(e) => tracing::warn!(
target: "lumotia_startup",
error = %e,
"transcript trash purge failed — trash rows remain for next startup"
),
}
// Load saved preferences for webview injection.
let t1 = Instant::now();
let prefs_json = get_setting(&db, "lumotia_preferences").await.unwrap_or(None);