1use super::DbPool;
8use crate::error::StorageError;
9
10#[derive(Debug, Clone, serde::Serialize)]
12pub struct ResetStats {
13 pub tables_cleared: u32,
15 pub rows_deleted: u64,
17}
18
19const TABLES_TO_CLEAR: &[&str] = &[
25 "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 "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
73pub 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 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 let stats = factory_reset(&pool).await.expect("factory reset");
147 assert_eq!(stats.tables_cleared, 44);
148 assert!(stats.rows_deleted >= 7);
150
151 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 factory_reset(&pool).await.expect("pre-clear");
186
187 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 let stats1 = factory_reset(&pool).await.expect("first reset");
208 assert_eq!(stats1.tables_cleared, 44);
209 assert_eq!(stats1.rows_deleted, 3);
211
212 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 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 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 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}