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