Skip to main content

mxr_store/
diagnostics.rs

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}