1use mxr_core::types::MessageFlags;
2use sqlx::SqlitePool;
3
4#[derive(Debug, Clone, Default, PartialEq, Eq)]
5pub struct StoreRecordCounts {
6 pub accounts: u32,
7 pub labels: u32,
8 pub messages: u32,
9 pub unread_messages: u32,
10 pub starred_messages: u32,
11 pub messages_with_attachments: u32,
12 pub message_labels: u32,
13 pub bodies: u32,
14 pub attachments: u32,
15 pub drafts: u32,
16 pub snoozed: u32,
17 pub saved_searches: u32,
18 pub rules: u32,
19 pub rule_logs: u32,
20 pub sync_log: u32,
21 pub sync_runtime_statuses: u32,
22 pub event_log: u32,
23 pub semantic_profiles: u32,
24 pub semantic_chunks: u32,
25 pub semantic_embeddings: u32,
26}
27
28impl super::Store {
29 pub async fn collect_record_counts(&self) -> Result<StoreRecordCounts, sqlx::Error> {
30 let read_flag = MessageFlags::READ.bits() as i64;
31 let starred_flag = MessageFlags::STARRED.bits() as i64;
32 let pool = self.reader();
33
34 Ok(StoreRecordCounts {
35 accounts: count_rows(pool, "SELECT COUNT(*) FROM accounts").await?,
36 labels: count_rows(pool, "SELECT COUNT(*) FROM labels").await?,
37 messages: count_rows(pool, "SELECT COUNT(*) FROM messages").await?,
38 unread_messages: count_bound_rows(
39 pool,
40 "SELECT COUNT(*) FROM messages WHERE (flags & ?) = 0",
41 read_flag,
42 )
43 .await?,
44 starred_messages: count_bound_rows(
45 pool,
46 "SELECT COUNT(*) FROM messages WHERE (flags & ?) != 0",
47 starred_flag,
48 )
49 .await?,
50 messages_with_attachments: count_rows(
51 pool,
52 "SELECT COUNT(*) FROM messages WHERE has_attachments = 1",
53 )
54 .await?,
55 message_labels: count_rows(pool, "SELECT COUNT(*) FROM message_labels").await?,
56 bodies: count_rows(pool, "SELECT COUNT(*) FROM bodies").await?,
57 attachments: count_rows(pool, "SELECT COUNT(*) FROM attachments").await?,
58 drafts: count_rows(pool, "SELECT COUNT(*) FROM drafts").await?,
59 snoozed: count_rows(pool, "SELECT COUNT(*) FROM snoozed").await?,
60 saved_searches: count_rows(pool, "SELECT COUNT(*) FROM saved_searches").await?,
61 rules: count_rows(pool, "SELECT COUNT(*) FROM rules").await?,
62 rule_logs: count_rows(pool, "SELECT COUNT(*) FROM rule_execution_log").await?,
63 sync_log: count_rows(pool, "SELECT COUNT(*) FROM sync_log").await?,
64 sync_runtime_statuses: count_rows(pool, "SELECT COUNT(*) FROM sync_runtime_status")
65 .await?,
66 event_log: count_rows(pool, "SELECT COUNT(*) FROM event_log").await?,
67 semantic_profiles: count_rows(pool, "SELECT COUNT(*) FROM semantic_profiles").await?,
68 semantic_chunks: count_rows(pool, "SELECT COUNT(*) FROM semantic_chunks").await?,
69 semantic_embeddings: count_rows(pool, "SELECT COUNT(*) FROM semantic_embeddings")
70 .await?,
71 })
72 }
73}
74
75async fn count_rows(pool: &SqlitePool, sql: &str) -> Result<u32, sqlx::Error> {
76 Ok(sqlx::query_scalar::<_, i64>(sql)
77 .fetch_one(pool)
78 .await?
79 .max(0) as u32)
80}
81
82async fn count_bound_rows(pool: &SqlitePool, sql: &str, value: i64) -> Result<u32, sqlx::Error> {
83 Ok(sqlx::query_scalar::<_, i64>(sql)
84 .bind(value)
85 .fetch_one(pool)
86 .await?
87 .max(0) as u32)
88}
89
90#[cfg(test)]
91mod tests {
92 use super::StoreRecordCounts;
93 use mxr_core::id::{
94 AccountId, DraftId, MessageId, SavedSearchId, SemanticChunkId, SemanticProfileId, ThreadId,
95 };
96 use mxr_core::types::{
97 Address, BackendRef, Draft, MessageBody, MessageFlags, ProviderKind, SearchMode,
98 SemanticChunkRecord, SemanticChunkSourceKind, SemanticEmbeddingRecord,
99 SemanticEmbeddingStatus, SemanticProfile, SemanticProfileRecord, SemanticProfileStatus,
100 Snoozed, SortOrder, UnsubscribeMethod,
101 };
102
103 #[tokio::test]
104 async fn collect_record_counts_reports_core_tables() {
105 let store = crate::Store::in_memory().await.unwrap();
106 let account = mxr_core::Account {
107 id: AccountId::new(),
108 name: "Test".into(),
109 email: "test@example.com".into(),
110 sync_backend: Some(BackendRef {
111 provider_kind: ProviderKind::Fake,
112 config_key: "fake".into(),
113 }),
114 send_backend: None,
115 enabled: true,
116 };
117 store.insert_account(&account).await.unwrap();
118
119 let label = mxr_core::types::Label {
120 id: mxr_core::id::LabelId::new(),
121 account_id: account.id.clone(),
122 name: "Inbox".into(),
123 kind: mxr_core::types::LabelKind::System,
124 color: None,
125 provider_id: "INBOX".into(),
126 unread_count: 0,
127 total_count: 0,
128 };
129 store.upsert_label(&label).await.unwrap();
130
131 let message_id = MessageId::new();
132 let envelope = mxr_core::types::Envelope {
133 id: message_id.clone(),
134 account_id: account.id.clone(),
135 provider_id: "fake-1".into(),
136 thread_id: ThreadId::new(),
137 message_id_header: Some("<msg@example.com>".into()),
138 in_reply_to: None,
139 references: vec![],
140 from: Address {
141 name: Some("Alice".into()),
142 email: "alice@example.com".into(),
143 },
144 to: vec![Address {
145 name: None,
146 email: "bob@example.com".into(),
147 }],
148 cc: vec![],
149 bcc: vec![],
150 subject: "Subject".into(),
151 date: chrono::Utc::now(),
152 flags: MessageFlags::STARRED,
153 snippet: "snippet".into(),
154 has_attachments: true,
155 size_bytes: 10,
156 unsubscribe: UnsubscribeMethod::None,
157 label_provider_ids: vec![label.provider_id.clone()],
158 };
159 store.upsert_envelope(&envelope).await.unwrap();
160 store
161 .set_message_labels(&message_id, std::slice::from_ref(&label.id))
162 .await
163 .unwrap();
164
165 let body = MessageBody {
166 message_id: message_id.clone(),
167 text_plain: Some("body".into()),
168 text_html: None,
169 attachments: vec![mxr_core::types::AttachmentMeta {
170 id: mxr_core::id::AttachmentId::new(),
171 message_id: message_id.clone(),
172 filename: "notes.txt".into(),
173 mime_type: "text/plain".into(),
174 size_bytes: 4,
175 local_path: None,
176 provider_id: "att-1".into(),
177 }],
178 fetched_at: chrono::Utc::now(),
179 metadata: Default::default(),
180 };
181 store.insert_body(&body).await.unwrap();
182
183 let saved = mxr_core::types::SavedSearch {
184 id: SavedSearchId::new(),
185 account_id: None,
186 name: "Unread".into(),
187 query: "is:unread".into(),
188 search_mode: SearchMode::Lexical,
189 sort: SortOrder::DateDesc,
190 icon: None,
191 position: 0,
192 created_at: chrono::Utc::now(),
193 };
194 store.insert_saved_search(&saved).await.unwrap();
195
196 let draft = Draft {
197 id: DraftId::new(),
198 account_id: account.id.clone(),
199 reply_headers: None,
200 to: vec![],
201 cc: vec![],
202 bcc: vec![],
203 subject: "draft".into(),
204 body_markdown: "body".into(),
205 attachments: vec![],
206 created_at: chrono::Utc::now(),
207 updated_at: chrono::Utc::now(),
208 };
209 store.insert_draft(&draft).await.unwrap();
210
211 let snoozed = Snoozed {
212 message_id: message_id.clone(),
213 account_id: account.id.clone(),
214 snoozed_at: chrono::Utc::now(),
215 wake_at: chrono::Utc::now(),
216 original_labels: vec![label.id.clone()],
217 };
218 store.insert_snooze(&snoozed).await.unwrap();
219
220 store
221 .upsert_rule(crate::RuleRecordInput {
222 id: "rule-1",
223 name: "Archive",
224 enabled: true,
225 priority: 10,
226 conditions_json: r#"{"type":"all"}"#,
227 actions_json: r#"[{"type":"archive"}]"#,
228 created_at: chrono::Utc::now(),
229 updated_at: chrono::Utc::now(),
230 })
231 .await
232 .unwrap();
233 let message_id_str = message_id.as_str();
234 store
235 .insert_rule_log(crate::RuleLogInput {
236 rule_id: "rule-1",
237 rule_name: "Archive",
238 message_id: &message_id_str,
239 actions_applied_json: r#"["archive"]"#,
240 timestamp: chrono::Utc::now(),
241 success: true,
242 error: None,
243 })
244 .await
245 .unwrap();
246 store
247 .insert_event("info", "sync", "Sync complete", None, None)
248 .await
249 .unwrap();
250
251 let profile = SemanticProfileRecord {
252 id: SemanticProfileId::new(),
253 profile: SemanticProfile::BgeSmallEnV15,
254 backend: "local".into(),
255 model_revision: "test".into(),
256 dimensions: 384,
257 status: SemanticProfileStatus::Ready,
258 installed_at: Some(chrono::Utc::now()),
259 activated_at: Some(chrono::Utc::now()),
260 last_indexed_at: Some(chrono::Utc::now()),
261 progress_completed: 1,
262 progress_total: 1,
263 last_error: None,
264 };
265 store.upsert_semantic_profile(&profile).await.unwrap();
266 let chunk = SemanticChunkRecord {
267 id: SemanticChunkId::new(),
268 message_id: message_id.clone(),
269 source_kind: SemanticChunkSourceKind::Body,
270 ordinal: 0,
271 normalized: "body".into(),
272 content_hash: "hash".into(),
273 created_at: chrono::Utc::now(),
274 updated_at: chrono::Utc::now(),
275 };
276 let embedding = SemanticEmbeddingRecord {
277 chunk_id: chunk.id.clone(),
278 profile_id: profile.id.clone(),
279 dimensions: 384,
280 vector: vec![0; 16],
281 status: SemanticEmbeddingStatus::Ready,
282 created_at: chrono::Utc::now(),
283 updated_at: chrono::Utc::now(),
284 };
285 store
286 .replace_semantic_message_data(
287 &message_id,
288 &profile.id,
289 std::slice::from_ref(&chunk),
290 std::slice::from_ref(&embedding),
291 )
292 .await
293 .unwrap();
294
295 let counts = store.collect_record_counts().await.unwrap();
296 assert_eq!(
297 counts,
298 StoreRecordCounts {
299 accounts: 1,
300 labels: 1,
301 messages: 1,
302 unread_messages: 1,
303 starred_messages: 1,
304 messages_with_attachments: 1,
305 message_labels: 1,
306 bodies: 1,
307 attachments: 1,
308 drafts: 1,
309 snoozed: 1,
310 saved_searches: 1,
311 rules: 1,
312 rule_logs: 1,
313 sync_log: 0,
314 sync_runtime_statuses: 0,
315 event_log: 1,
316 semantic_profiles: 1,
317 semantic_chunks: 1,
318 semantic_embeddings: 1,
319 }
320 );
321 }
322}