Skip to main content

tuitbot_core/storage/
reset.rs

1//! Factory reset: clear all user data from the database.
2//!
3//! Deletes rows from all 31 user tables in FK-safe order within a single
4//! transaction. Preserves the schema and `_sqlx_migrations` so the pool
5//! and migration tracking remain usable.
6
7use super::DbPool;
8use crate::error::StorageError;
9
10/// Statistics returned from a factory reset operation.
11#[derive(Debug, Clone, serde::Serialize)]
12pub struct ResetStats {
13    /// Number of tables cleared.
14    pub tables_cleared: u32,
15    /// Total number of rows deleted across all tables.
16    pub rows_deleted: u64,
17}
18
19/// FK-safe table deletion order (children before parents).
20///
21/// Derived from the 20 migration SQL files. Tables with foreign key
22/// constraints appear first so their rows are deleted before the
23/// referenced parent rows.
24const TABLES_TO_CLEAR: &[&str] = &[
25    // FK-constrained tables (children first)
26    "draft_seeds",
27    "original_tweets",
28    "content_nodes",
29    "connections",
30    "thread_tweets",
31    "account_roles",
32    "target_tweets",
33    "approval_edit_history",
34    // No FK constraints below this line
35    "reply_performance",
36    "tweet_performance",
37    "replies_sent",
38    "discovered_tweets",
39    "threads",
40    "approval_queue",
41    "scheduled_content",
42    "target_accounts",
43    "follower_snapshots",
44    "content_scores",
45    "strategy_reports",
46    "rate_limits",
47    "action_log",
48    "cursors",
49    "author_interactions",
50    "media_uploads",
51    "llm_usage",
52    "x_api_usage",
53    "mcp_telemetry",
54    "mutation_audit",
55    "source_contexts",
56    "sessions",
57    "accounts",
58];
59
60/// Clear all user data from the database within a single transaction.
61///
62/// Deletes all rows from 31 user tables in FK-safe order.
63/// Preserves the schema (tables, indexes) and `_sqlx_migrations`.
64///
65/// Table names come from the compile-time `TABLES_TO_CLEAR` constant --
66/// not from user input -- so the `format!` is safe from injection.
67pub async fn factory_reset(pool: &DbPool) -> Result<ResetStats, StorageError> {
68    let mut tx = pool
69        .begin()
70        .await
71        .map_err(|e| StorageError::Connection { source: e })?;
72
73    let mut rows_deleted: u64 = 0;
74    let mut tables_cleared: u32 = 0;
75
76    for table in TABLES_TO_CLEAR {
77        let query = format!("DELETE FROM {table}");
78        let result = sqlx::query(&query)
79            .execute(&mut *tx)
80            .await
81            .map_err(|e| StorageError::Query { source: e })?;
82        rows_deleted += result.rows_affected();
83        tables_cleared += 1;
84    }
85
86    tx.commit()
87        .await
88        .map_err(|e| StorageError::Connection { source: e })?;
89
90    Ok(ResetStats {
91        tables_cleared,
92        rows_deleted,
93    })
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::storage::init_test_db;
100
101    #[tokio::test]
102    async fn factory_reset_clears_all_tables() {
103        let pool = init_test_db().await.expect("init test db");
104
105        // Insert additional sample data beyond migration seeds.
106        sqlx::query("INSERT INTO accounts (id, label) VALUES ('acc1', 'Extra')")
107            .execute(&pool)
108            .await
109            .unwrap();
110
111        sqlx::query(
112            "INSERT INTO account_roles (account_id, actor, role) \
113             VALUES ('acc1', 'dashboard', 'composer')",
114        )
115        .execute(&pool)
116        .await
117        .unwrap();
118
119        sqlx::query("INSERT INTO target_accounts (account_id, username) VALUES ('t1', 'target1')")
120            .execute(&pool)
121            .await
122            .unwrap();
123
124        sqlx::query(
125            "INSERT INTO discovered_tweets (id, author_id, author_username, content) \
126             VALUES ('tw1', 'auth1', 'someone', 'hello world')",
127        )
128        .execute(&pool)
129        .await
130        .unwrap();
131
132        // Run factory reset.
133        let stats = factory_reset(&pool).await.expect("factory reset");
134        assert_eq!(stats.tables_cleared, 31);
135        // Migration seeds 1 account + 2 account_roles = 3 rows, plus our 4 = 7.
136        assert!(stats.rows_deleted >= 7);
137
138        // Verify all tables are empty.
139        for table in TABLES_TO_CLEAR {
140            let count: (i64,) = sqlx::query_as(&format!("SELECT COUNT(*) FROM {table}"))
141                .fetch_one(&pool)
142                .await
143                .unwrap();
144            assert_eq!(count.0, 0, "table {table} should be empty after reset");
145        }
146    }
147
148    #[tokio::test]
149    async fn factory_reset_preserves_migrations() {
150        let pool = init_test_db().await.expect("init test db");
151
152        let before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations")
153            .fetch_one(&pool)
154            .await
155            .unwrap();
156        assert!(before.0 > 0, "migrations table should have entries");
157
158        factory_reset(&pool).await.expect("factory reset");
159
160        let after: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations")
161            .fetch_one(&pool)
162            .await
163            .unwrap();
164        assert_eq!(before.0, after.0, "migrations should be untouched");
165    }
166
167    #[tokio::test]
168    async fn factory_reset_returns_accurate_stats() {
169        let pool = init_test_db().await.expect("init test db");
170
171        // Clear migration-seeded data first so we start from a known state.
172        factory_reset(&pool).await.expect("pre-clear");
173
174        // Insert exactly 2 rows.
175        sqlx::query("INSERT INTO accounts (id, label) VALUES ('a1', 'U1')")
176            .execute(&pool)
177            .await
178            .unwrap();
179        sqlx::query("INSERT INTO accounts (id, label) VALUES ('a2', 'U2')")
180            .execute(&pool)
181            .await
182            .unwrap();
183
184        let stats = factory_reset(&pool).await.expect("factory reset");
185        assert_eq!(stats.tables_cleared, 31);
186        assert_eq!(stats.rows_deleted, 2);
187    }
188
189    #[tokio::test]
190    async fn factory_reset_idempotent() {
191        let pool = init_test_db().await.expect("init test db");
192
193        // First reset clears migration-seeded rows.
194        let stats1 = factory_reset(&pool).await.expect("first reset");
195        assert_eq!(stats1.tables_cleared, 31);
196        // Migration seeds 1 account + 2 account_roles = 3 rows.
197        assert_eq!(stats1.rows_deleted, 3);
198
199        // Second reset on now-empty DB succeeds with 0 rows.
200        let stats2 = factory_reset(&pool).await.expect("second reset");
201        assert_eq!(stats2.tables_cleared, 31);
202        assert_eq!(stats2.rows_deleted, 0);
203    }
204
205    #[tokio::test]
206    async fn tables_to_clear_covers_all_user_tables() {
207        let pool = init_test_db().await.expect("init test db");
208
209        // Query sqlite_master for all user tables (excluding internal ones).
210        let all_tables: Vec<(String,)> = sqlx::query_as(
211            "SELECT name FROM sqlite_master \
212             WHERE type='table' \
213             AND name NOT LIKE 'sqlite_%' \
214             AND name != '_sqlx_migrations' \
215             ORDER BY name",
216        )
217        .fetch_all(&pool)
218        .await
219        .unwrap();
220
221        let table_names: Vec<&str> = all_tables.iter().map(|t| t.0.as_str()).collect();
222
223        // Every table in the DB should be in TABLES_TO_CLEAR.
224        for name in &table_names {
225            assert!(
226                TABLES_TO_CLEAR.contains(name),
227                "table '{name}' exists in DB but is missing from TABLES_TO_CLEAR"
228            );
229        }
230
231        // Every table in TABLES_TO_CLEAR should exist in the DB.
232        for name in TABLES_TO_CLEAR {
233            assert!(
234                table_names.contains(name),
235                "table '{name}' is in TABLES_TO_CLEAR but does not exist in DB"
236            );
237        }
238    }
239}