Skip to main content

codemem_storage/
queries.rs

1//! Stats, consolidation, pattern queries, and session management on Storage.
2
3use crate::Storage;
4use codemem_core::{CodememError, ConsolidationLogEntry, Session, StorageStats};
5use rusqlite::params;
6
7impl Storage {
8    // ── Health / Diagnostics ────────────────────────────────────────────
9
10    /// Run SQLite `PRAGMA integrity_check`. Returns `true` if the database is OK.
11    pub fn integrity_check(&self) -> Result<bool, CodememError> {
12        let conn = self.conn()?;
13        let result: String = conn
14            .query_row("PRAGMA integrity_check", [], |row| row.get(0))
15            .map_err(|e| CodememError::Storage(e.to_string()))?;
16        Ok(result == "ok")
17    }
18
19    /// Return the current schema version (max applied migration number).
20    pub fn schema_version(&self) -> Result<u32, CodememError> {
21        let conn = self.conn()?;
22        let version: u32 = conn
23            .query_row(
24                "SELECT COALESCE(MAX(version), 0) FROM schema_version",
25                [],
26                |row| row.get(0),
27            )
28            .map_err(|e| CodememError::Storage(e.to_string()))?;
29        Ok(version)
30    }
31
32    // ── Stats ───────────────────────────────────────────────────────────
33
34    /// Get database statistics in a single query.
35    pub fn stats(&self) -> Result<StorageStats, CodememError> {
36        let conn = self.conn()?;
37
38        let (memory_count, embedding_count, node_count, edge_count) = conn
39            .query_row(
40                "SELECT
41                    (SELECT COUNT(*) FROM memories) AS memory_count,
42                    (SELECT COUNT(*) FROM memory_embeddings) AS embedding_count,
43                    (SELECT COUNT(*) FROM graph_nodes) AS node_count,
44                    (SELECT COUNT(*) FROM graph_edges) AS edge_count",
45                [],
46                |row| {
47                    Ok((
48                        row.get::<_, i64>(0)?,
49                        row.get::<_, i64>(1)?,
50                        row.get::<_, i64>(2)?,
51                        row.get::<_, i64>(3)?,
52                    ))
53                },
54            )
55            .map_err(|e| CodememError::Storage(e.to_string()))?;
56
57        Ok(StorageStats {
58            memory_count: memory_count as usize,
59            embedding_count: embedding_count as usize,
60            node_count: node_count as usize,
61            edge_count: edge_count as usize,
62        })
63    }
64
65    // ── Consolidation Log ──────────────────────────────────────────────
66
67    /// Record a consolidation run.
68    pub fn insert_consolidation_log(
69        &self,
70        cycle_type: &str,
71        affected_count: usize,
72    ) -> Result<(), CodememError> {
73        let conn = self.conn()?;
74        let now = chrono::Utc::now().timestamp();
75        conn.execute(
76            "INSERT INTO consolidation_log (cycle_type, run_at, affected_count) VALUES (?1, ?2, ?3)",
77            params![cycle_type, now, affected_count as i64],
78        )
79        .map_err(|e| CodememError::Storage(e.to_string()))?;
80        Ok(())
81    }
82
83    /// Get the last consolidation run for each cycle type.
84    pub fn last_consolidation_runs(&self) -> Result<Vec<ConsolidationLogEntry>, CodememError> {
85        let conn = self.conn()?;
86        let mut stmt = conn
87            .prepare(
88                "SELECT cycle_type, run_at, affected_count FROM consolidation_log
89                 WHERE (cycle_type, run_at) IN (
90                     SELECT cycle_type, MAX(run_at) FROM consolidation_log GROUP BY cycle_type
91                 )
92                 ORDER BY cycle_type",
93            )
94            .map_err(|e| CodememError::Storage(e.to_string()))?;
95
96        let entries = stmt
97            .query_map([], |row| {
98                Ok(ConsolidationLogEntry {
99                    cycle_type: row.get(0)?,
100                    run_at: row.get(1)?,
101                    affected_count: row.get::<_, i64>(2)? as usize,
102                })
103            })
104            .map_err(|e| CodememError::Storage(e.to_string()))?
105            .collect::<Result<Vec<_>, _>>()
106            .map_err(|e| CodememError::Storage(e.to_string()))?;
107
108        Ok(entries)
109    }
110
111    // ── Pattern Detection Queries ───────────────────────────────────────
112
113    /// Find repeated search patterns (Grep/Glob) by extracting the "pattern" field
114    /// from memory metadata JSON. Returns (pattern, count, memory_ids) tuples where
115    /// count >= min_count, ordered by count descending.
116    pub fn get_repeated_searches(
117        &self,
118        min_count: usize,
119        namespace: Option<&str>,
120    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
121        let conn = self.conn()?;
122        let sql = if namespace.is_some() {
123            "SELECT json_extract(metadata, '$.pattern') AS pat,
124                    COUNT(*) AS cnt,
125                    GROUP_CONCAT(id, ',') AS ids
126             FROM memories
127             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
128               AND pat IS NOT NULL
129               AND namespace = ?1
130             GROUP BY pat
131             HAVING cnt >= ?2
132             ORDER BY cnt DESC"
133        } else {
134            "SELECT json_extract(metadata, '$.pattern') AS pat,
135                    COUNT(*) AS cnt,
136                    GROUP_CONCAT(id, ',') AS ids
137             FROM memories
138             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
139               AND pat IS NOT NULL
140             GROUP BY pat
141             HAVING cnt >= ?1
142             ORDER BY cnt DESC"
143        };
144
145        let mut stmt = conn
146            .prepare(sql)
147            .map_err(|e| CodememError::Storage(e.to_string()))?;
148
149        let rows = if let Some(ns) = namespace {
150            stmt.query_map(params![ns, min_count as i64], |row| {
151                Ok((
152                    row.get::<_, String>(0)?,
153                    row.get::<_, i64>(1)?,
154                    row.get::<_, String>(2)?,
155                ))
156            })
157            .map_err(|e| CodememError::Storage(e.to_string()))?
158            .collect::<Result<Vec<_>, _>>()
159            .map_err(|e| CodememError::Storage(e.to_string()))?
160        } else {
161            stmt.query_map(params![min_count as i64], |row| {
162                Ok((
163                    row.get::<_, String>(0)?,
164                    row.get::<_, i64>(1)?,
165                    row.get::<_, String>(2)?,
166                ))
167            })
168            .map_err(|e| CodememError::Storage(e.to_string()))?
169            .collect::<Result<Vec<_>, _>>()
170            .map_err(|e| CodememError::Storage(e.to_string()))?
171        };
172
173        Ok(rows
174            .into_iter()
175            .map(|(pat, cnt, ids_str)| {
176                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
177                (pat, cnt as usize, ids)
178            })
179            .collect())
180    }
181
182    /// Find file hotspots by extracting the "file_path" field from memory metadata.
183    pub fn get_file_hotspots(
184        &self,
185        min_count: usize,
186        namespace: Option<&str>,
187    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
188        let conn = self.conn()?;
189        let sql = if namespace.is_some() {
190            "SELECT json_extract(metadata, '$.file_path') AS fp,
191                    COUNT(*) AS cnt,
192                    GROUP_CONCAT(id, ',') AS ids
193             FROM memories
194             WHERE fp IS NOT NULL
195               AND namespace = ?1
196             GROUP BY fp
197             HAVING cnt >= ?2
198             ORDER BY cnt DESC"
199        } else {
200            "SELECT json_extract(metadata, '$.file_path') AS fp,
201                    COUNT(*) AS cnt,
202                    GROUP_CONCAT(id, ',') AS ids
203             FROM memories
204             WHERE fp IS NOT NULL
205             GROUP BY fp
206             HAVING cnt >= ?1
207             ORDER BY cnt DESC"
208        };
209
210        let mut stmt = conn
211            .prepare(sql)
212            .map_err(|e| CodememError::Storage(e.to_string()))?;
213
214        let rows = if let Some(ns) = namespace {
215            stmt.query_map(params![ns, min_count as i64], |row| {
216                Ok((
217                    row.get::<_, String>(0)?,
218                    row.get::<_, i64>(1)?,
219                    row.get::<_, String>(2)?,
220                ))
221            })
222            .map_err(|e| CodememError::Storage(e.to_string()))?
223            .collect::<Result<Vec<_>, _>>()
224            .map_err(|e| CodememError::Storage(e.to_string()))?
225        } else {
226            stmt.query_map(params![min_count as i64], |row| {
227                Ok((
228                    row.get::<_, String>(0)?,
229                    row.get::<_, i64>(1)?,
230                    row.get::<_, String>(2)?,
231                ))
232            })
233            .map_err(|e| CodememError::Storage(e.to_string()))?
234            .collect::<Result<Vec<_>, _>>()
235            .map_err(|e| CodememError::Storage(e.to_string()))?
236        };
237
238        Ok(rows
239            .into_iter()
240            .map(|(fp, cnt, ids_str)| {
241                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
242                (fp, cnt as usize, ids)
243            })
244            .collect())
245    }
246
247    /// Get tool usage statistics from memory metadata.
248    /// Returns (tool_name, count) pairs sorted by count descending.
249    pub fn get_tool_usage_stats(
250        &self,
251        namespace: Option<&str>,
252    ) -> Result<Vec<(String, usize)>, CodememError> {
253        let conn = self.conn()?;
254        let sql = if namespace.is_some() {
255            "SELECT json_extract(metadata, '$.tool') AS tool,
256                    COUNT(*) AS cnt
257             FROM memories
258             WHERE tool IS NOT NULL
259               AND namespace = ?1
260             GROUP BY tool
261             ORDER BY cnt DESC"
262        } else {
263            "SELECT json_extract(metadata, '$.tool') AS tool,
264                    COUNT(*) AS cnt
265             FROM memories
266             WHERE tool IS NOT NULL
267             GROUP BY tool
268             ORDER BY cnt DESC"
269        };
270
271        let mut stmt = conn
272            .prepare(sql)
273            .map_err(|e| CodememError::Storage(e.to_string()))?;
274
275        let rows = if let Some(ns) = namespace {
276            stmt.query_map(params![ns], |row| {
277                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
278            })
279            .map_err(|e| CodememError::Storage(e.to_string()))?
280            .collect::<Result<Vec<_>, _>>()
281            .map_err(|e| CodememError::Storage(e.to_string()))?
282        } else {
283            stmt.query_map([], |row| {
284                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
285            })
286            .map_err(|e| CodememError::Storage(e.to_string()))?
287            .collect::<Result<Vec<_>, _>>()
288            .map_err(|e| CodememError::Storage(e.to_string()))?
289        };
290
291        Ok(rows
292            .into_iter()
293            .map(|(tool, cnt)| (tool, cnt as usize))
294            .collect())
295    }
296
297    /// Find decision chains: files with multiple Edit/Write memories over time.
298    pub fn get_decision_chains(
299        &self,
300        min_count: usize,
301        namespace: Option<&str>,
302    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
303        let conn = self.conn()?;
304        let sql = if namespace.is_some() {
305            "SELECT json_extract(metadata, '$.file_path') AS fp,
306                    COUNT(*) AS cnt,
307                    GROUP_CONCAT(id, ',') AS ids
308             FROM memories
309             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
310               AND fp IS NOT NULL
311               AND namespace = ?1
312             GROUP BY fp
313             HAVING cnt >= ?2
314             ORDER BY cnt DESC"
315        } else {
316            "SELECT json_extract(metadata, '$.file_path') AS fp,
317                    COUNT(*) AS cnt,
318                    GROUP_CONCAT(id, ',') AS ids
319             FROM memories
320             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
321               AND fp IS NOT NULL
322             GROUP BY fp
323             HAVING cnt >= ?1
324             ORDER BY cnt DESC"
325        };
326
327        let mut stmt = conn
328            .prepare(sql)
329            .map_err(|e| CodememError::Storage(e.to_string()))?;
330
331        let rows = if let Some(ns) = namespace {
332            stmt.query_map(params![ns, min_count as i64], |row| {
333                Ok((
334                    row.get::<_, String>(0)?,
335                    row.get::<_, i64>(1)?,
336                    row.get::<_, String>(2)?,
337                ))
338            })
339            .map_err(|e| CodememError::Storage(e.to_string()))?
340            .collect::<Result<Vec<_>, _>>()
341            .map_err(|e| CodememError::Storage(e.to_string()))?
342        } else {
343            stmt.query_map(params![min_count as i64], |row| {
344                Ok((
345                    row.get::<_, String>(0)?,
346                    row.get::<_, i64>(1)?,
347                    row.get::<_, String>(2)?,
348                ))
349            })
350            .map_err(|e| CodememError::Storage(e.to_string()))?
351            .collect::<Result<Vec<_>, _>>()
352            .map_err(|e| CodememError::Storage(e.to_string()))?
353        };
354
355        Ok(rows
356            .into_iter()
357            .map(|(fp, cnt, ids_str)| {
358                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
359                (fp, cnt as usize, ids)
360            })
361            .collect())
362    }
363
364    // ── Insight / Tag Queries ──────────────────────────────────────────
365
366    /// Count memories whose content matches any of the given keywords (SQL LIKE).
367    pub fn count_memories_matching_keywords(
368        &self,
369        keywords: &[&str],
370        namespace: Option<&str>,
371    ) -> Result<usize, CodememError> {
372        if keywords.is_empty() {
373            return Ok(0);
374        }
375        let conn = self.conn()?;
376        let like_clauses: Vec<String> = keywords
377            .iter()
378            .enumerate()
379            .map(|(i, _)| format!("content LIKE ?{}", i + 1))
380            .collect();
381        let where_likes = like_clauses.join(" OR ");
382
383        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
384            if let Some(ns) = namespace {
385                let sql = format!(
386                    "SELECT COUNT(*) FROM memories WHERE ({}) AND namespace = ?{}",
387                    where_likes,
388                    keywords.len() + 1,
389                );
390                let mut p: Vec<Box<dyn rusqlite::types::ToSql>> = keywords
391                    .iter()
392                    .map(|k| Box::new(format!("%{k}%")) as Box<dyn rusqlite::types::ToSql>)
393                    .collect();
394                p.push(Box::new(ns.to_string()));
395                (sql, p)
396            } else {
397                let sql = format!("SELECT COUNT(*) FROM memories WHERE ({})", where_likes);
398                let p: Vec<Box<dyn rusqlite::types::ToSql>> = keywords
399                    .iter()
400                    .map(|k| Box::new(format!("%{k}%")) as Box<dyn rusqlite::types::ToSql>)
401                    .collect();
402                (sql, p)
403            };
404
405        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
406            params_vec.iter().map(|b| &**b).collect();
407
408        let count: i64 = conn
409            .query_row(&sql, params_refs.as_slice(), |row| row.get(0))
410            .map_err(|e| CodememError::Storage(e.to_string()))?;
411        Ok(count as usize)
412    }
413
414    /// List memories that contain a specific tag, optionally scoped to a namespace.
415    /// Uses `json_each` for proper JSON array querying instead of LIKE patterns.
416    pub fn list_memories_by_tag(
417        &self,
418        tag: &str,
419        namespace: Option<&str>,
420        limit: usize,
421    ) -> Result<Vec<codemem_core::MemoryNode>, CodememError> {
422        let conn = self.conn()?;
423
424        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(ns) =
425            namespace
426        {
427            (
428                "SELECT m.id, m.content, m.memory_type, m.importance, m.confidence, m.access_count, \
429                 m.content_hash, m.tags, m.metadata, m.namespace, m.created_at, m.updated_at, m.last_accessed_at \
430                 FROM memories m, json_each(m.tags) AS jt \
431                 WHERE jt.value = ?1 AND m.namespace = ?2 \
432                 ORDER BY m.created_at DESC LIMIT ?3"
433                    .to_string(),
434                vec![
435                    Box::new(tag.to_string()) as Box<dyn rusqlite::types::ToSql>,
436                    Box::new(ns.to_string()),
437                    Box::new(limit as i64),
438                ],
439            )
440        } else {
441            (
442                "SELECT m.id, m.content, m.memory_type, m.importance, m.confidence, m.access_count, \
443                 m.content_hash, m.tags, m.metadata, m.namespace, m.created_at, m.updated_at, m.last_accessed_at \
444                 FROM memories m, json_each(m.tags) AS jt \
445                 WHERE jt.value = ?1 \
446                 ORDER BY m.created_at DESC LIMIT ?2"
447                    .to_string(),
448                vec![
449                    Box::new(tag.to_string()) as Box<dyn rusqlite::types::ToSql>,
450                    Box::new(limit as i64),
451                ],
452            )
453        };
454
455        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
456            params_vec.iter().map(|b| &**b).collect();
457
458        let mut stmt = conn
459            .prepare(&sql)
460            .map_err(|e| CodememError::Storage(e.to_string()))?;
461
462        let rows = stmt
463            .query_map(params_refs.as_slice(), |row| {
464                let created_ts: i64 = row.get(10)?;
465                let updated_ts: i64 = row.get(11)?;
466                let accessed_ts: i64 = row.get(12)?;
467                let tags_json: String = row.get(7)?;
468                let metadata_json: String = row.get(8)?;
469                let memory_type_str: String = row.get(2)?;
470
471                Ok(codemem_core::MemoryNode {
472                    id: row.get(0)?,
473                    content: row.get(1)?,
474                    memory_type: memory_type_str
475                        .parse()
476                        .unwrap_or(codemem_core::MemoryType::Context),
477                    importance: row.get(3)?,
478                    confidence: row.get(4)?,
479                    access_count: row.get::<_, i64>(5).unwrap_or(0) as u32,
480                    content_hash: row.get(6)?,
481                    tags: serde_json::from_str(&tags_json).unwrap_or_default(),
482                    metadata: serde_json::from_str(&metadata_json).unwrap_or_default(),
483                    namespace: row.get(9)?,
484                    created_at: chrono::DateTime::from_timestamp(created_ts, 0)
485                        .unwrap_or_default()
486                        .with_timezone(&chrono::Utc),
487                    updated_at: chrono::DateTime::from_timestamp(updated_ts, 0)
488                        .unwrap_or_default()
489                        .with_timezone(&chrono::Utc),
490                    last_accessed_at: chrono::DateTime::from_timestamp(accessed_ts, 0)
491                        .unwrap_or_default()
492                        .with_timezone(&chrono::Utc),
493                })
494            })
495            .map_err(|e| CodememError::Storage(e.to_string()))?
496            .collect::<Result<Vec<_>, _>>()
497            .map_err(|e| CodememError::Storage(e.to_string()))?;
498
499        Ok(rows)
500    }
501
502    // ── Session Management ─────────────────────────────────────────────
503
504    /// Start a new session.
505    pub fn start_session(&self, id: &str, namespace: Option<&str>) -> Result<(), CodememError> {
506        let conn = self.conn()?;
507        let now = chrono::Utc::now().timestamp();
508        conn.execute(
509            "INSERT OR IGNORE INTO sessions (id, namespace, started_at) VALUES (?1, ?2, ?3)",
510            params![id, namespace, now],
511        )
512        .map_err(|e| CodememError::Storage(e.to_string()))?;
513        Ok(())
514    }
515
516    /// End a session by setting ended_at and optionally a summary.
517    pub fn end_session(&self, id: &str, summary: Option<&str>) -> Result<(), CodememError> {
518        let conn = self.conn()?;
519        let now = chrono::Utc::now().timestamp();
520        conn.execute(
521            "UPDATE sessions SET ended_at = ?1, summary = ?2 WHERE id = ?3",
522            params![now, summary, id],
523        )
524        .map_err(|e| CodememError::Storage(e.to_string()))?;
525        Ok(())
526    }
527
528    /// List sessions, optionally filtered by namespace.
529    pub fn list_sessions(&self, namespace: Option<&str>) -> Result<Vec<Session>, CodememError> {
530        self.list_sessions_with_limit(namespace, usize::MAX)
531    }
532
533    // ── Session Activity Tracking ─────────────────────────────────
534
535    /// Record a session activity event.
536    pub fn record_session_activity(
537        &self,
538        session_id: &str,
539        tool_name: &str,
540        file_path: Option<&str>,
541        directory: Option<&str>,
542        pattern: Option<&str>,
543    ) -> Result<(), CodememError> {
544        let conn = self.conn()?;
545        let now = chrono::Utc::now().timestamp();
546        conn.execute(
547            "INSERT INTO session_activity (session_id, tool_name, file_path, directory, pattern, created_at)
548             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
549            params![session_id, tool_name, file_path, directory, pattern, now],
550        )
551        .map_err(|e| CodememError::Storage(e.to_string()))?;
552        Ok(())
553    }
554
555    /// Get a summary of session activity counts using a single query with conditional aggregation.
556    pub fn get_session_activity_summary(
557        &self,
558        session_id: &str,
559    ) -> Result<codemem_core::SessionActivitySummary, CodememError> {
560        let conn = self.conn()?;
561
562        let (files_read, files_edited, searches, total_actions) = conn
563            .query_row(
564                "SELECT
565                     COUNT(DISTINCT CASE WHEN tool_name = 'Read' AND file_path IS NOT NULL THEN file_path END),
566                     COUNT(DISTINCT CASE WHEN tool_name IN ('Edit', 'Write') AND file_path IS NOT NULL THEN file_path END),
567                     SUM(CASE WHEN tool_name IN ('Grep', 'Glob') THEN 1 ELSE 0 END),
568                     COUNT(*)
569                 FROM session_activity
570                 WHERE session_id = ?1",
571                params![session_id],
572                |row| {
573                    Ok((
574                        row.get::<_, i64>(0)?,
575                        row.get::<_, i64>(1)?,
576                        row.get::<_, i64>(2)?,
577                        row.get::<_, i64>(3)?,
578                    ))
579                },
580            )
581            .map_err(|e| CodememError::Storage(e.to_string()))?;
582
583        Ok(codemem_core::SessionActivitySummary {
584            files_read: files_read as usize,
585            files_edited: files_edited as usize,
586            searches: searches as usize,
587            total_actions: total_actions as usize,
588        })
589    }
590
591    /// Get the most active directories in a session.
592    pub fn get_session_hot_directories(
593        &self,
594        session_id: &str,
595        limit: usize,
596    ) -> Result<Vec<(String, usize)>, CodememError> {
597        let conn = self.conn()?;
598        let mut stmt = conn
599            .prepare(
600                "SELECT directory, COUNT(*) AS cnt FROM session_activity
601                 WHERE session_id = ?1 AND directory IS NOT NULL
602                 GROUP BY directory ORDER BY cnt DESC LIMIT ?2",
603            )
604            .map_err(|e| CodememError::Storage(e.to_string()))?;
605
606        let rows = stmt
607            .query_map(params![session_id, limit as i64], |row| {
608                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
609            })
610            .map_err(|e| CodememError::Storage(e.to_string()))?
611            .collect::<Result<Vec<_>, _>>()
612            .map_err(|e| CodememError::Storage(e.to_string()))?;
613
614        Ok(rows
615            .into_iter()
616            .map(|(dir, cnt)| (dir, cnt as usize))
617            .collect())
618    }
619
620    /// Check whether an auto-insight dedup tag exists for a session.
621    /// Uses `json_extract` with proper parameter binding instead of LIKE on JSON.
622    pub fn has_auto_insight(
623        &self,
624        session_id: &str,
625        dedup_tag: &str,
626    ) -> Result<bool, CodememError> {
627        let conn = self.conn()?;
628        let count: i64 = conn
629            .query_row(
630                "SELECT COUNT(*) FROM memories
631                 WHERE json_extract(metadata, '$.session_id') = ?1
632                   AND json_extract(metadata, '$.auto_insight_tag') = ?2",
633                params![session_id, dedup_tag],
634                |row| row.get(0),
635            )
636            .map_err(|e| CodememError::Storage(e.to_string()))?;
637        Ok(count > 0)
638    }
639
640    /// Count Read events in a directory during a session.
641    pub fn count_directory_reads(
642        &self,
643        session_id: &str,
644        directory: &str,
645    ) -> Result<usize, CodememError> {
646        let conn = self.conn()?;
647        let count: i64 = conn
648            .query_row(
649                "SELECT COUNT(*) FROM session_activity
650                 WHERE session_id = ?1 AND tool_name = 'Read' AND directory = ?2",
651                params![session_id, directory],
652                |row| row.get(0),
653            )
654            .map_err(|e| CodememError::Storage(e.to_string()))?;
655        Ok(count as usize)
656    }
657
658    /// Check if a file was read in the current session.
659    pub fn was_file_read_in_session(
660        &self,
661        session_id: &str,
662        file_path: &str,
663    ) -> Result<bool, CodememError> {
664        let conn = self.conn()?;
665        let count: i64 = conn
666            .query_row(
667                "SELECT COUNT(*) FROM session_activity
668                 WHERE session_id = ?1 AND tool_name = 'Read' AND file_path = ?2",
669                params![session_id, file_path],
670                |row| row.get(0),
671            )
672            .map_err(|e| CodememError::Storage(e.to_string()))?;
673        Ok(count > 0)
674    }
675
676    /// Count how many times a search pattern was used in a session.
677    pub fn count_search_pattern_in_session(
678        &self,
679        session_id: &str,
680        pattern: &str,
681    ) -> Result<usize, CodememError> {
682        let conn = self.conn()?;
683        let count: i64 = conn
684            .query_row(
685                "SELECT COUNT(*) FROM session_activity
686                 WHERE session_id = ?1 AND tool_name IN ('Grep', 'Glob') AND pattern = ?2",
687                params![session_id, pattern],
688                |row| row.get(0),
689            )
690            .map_err(|e| CodememError::Storage(e.to_string()))?;
691        Ok(count as usize)
692    }
693
694    /// List sessions with a limit.
695    pub(crate) fn list_sessions_with_limit(
696        &self,
697        namespace: Option<&str>,
698        limit: usize,
699    ) -> Result<Vec<Session>, CodememError> {
700        let conn = self.conn()?;
701        let sql_with_ns = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions WHERE namespace = ?1 ORDER BY started_at DESC LIMIT ?2";
702        let sql_all = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions ORDER BY started_at DESC LIMIT ?1";
703
704        let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Session> {
705            let started_ts: i64 = row.get(2)?;
706            let ended_ts: Option<i64> = row.get(3)?;
707            Ok(Session {
708                id: row.get(0)?,
709                namespace: row.get(1)?,
710                started_at: chrono::DateTime::from_timestamp(started_ts, 0)
711                    .unwrap_or_default()
712                    .with_timezone(&chrono::Utc),
713                ended_at: ended_ts.and_then(|ts| {
714                    chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&chrono::Utc))
715                }),
716                memory_count: row.get::<_, i64>(4).unwrap_or(0) as u32,
717                summary: row.get(5)?,
718            })
719        };
720
721        if let Some(ns) = namespace {
722            let mut stmt = conn
723                .prepare(sql_with_ns)
724                .map_err(|e| CodememError::Storage(e.to_string()))?;
725            let rows = stmt
726                .query_map(params![ns, limit as i64], map_row)
727                .map_err(|e| CodememError::Storage(e.to_string()))?;
728            rows.collect::<Result<Vec<_>, _>>()
729                .map_err(|e| CodememError::Storage(e.to_string()))
730        } else {
731            let mut stmt = conn
732                .prepare(sql_all)
733                .map_err(|e| CodememError::Storage(e.to_string()))?;
734            let rows = stmt
735                .query_map(params![limit as i64], map_row)
736                .map_err(|e| CodememError::Storage(e.to_string()))?;
737            rows.collect::<Result<Vec<_>, _>>()
738                .map_err(|e| CodememError::Storage(e.to_string()))
739        }
740    }
741    // ── Graph Cleanup ───────────────────────────────────────────────
742
743    /// Delete all graph nodes, their edges, and their embeddings where the
744    /// node ID starts with the given prefix. Returns count of nodes deleted.
745    /// Wrapped in a transaction so all three DELETEs are atomic.
746    pub fn delete_graph_nodes_by_prefix(&self, prefix: &str) -> Result<usize, CodememError> {
747        let conn = self.conn()?;
748        let like_pattern = format!("{prefix}%");
749
750        let tx = conn
751            .unchecked_transaction()
752            .map_err(|e| CodememError::Storage(e.to_string()))?;
753
754        // Delete edges where src or dst matches prefix
755        tx.execute(
756            "DELETE FROM graph_edges WHERE src LIKE ?1 OR dst LIKE ?1",
757            params![like_pattern],
758        )
759        .map_err(|e| CodememError::Storage(e.to_string()))?;
760
761        // Delete embeddings for matching nodes
762        tx.execute(
763            "DELETE FROM memory_embeddings WHERE memory_id LIKE ?1",
764            params![like_pattern],
765        )
766        .map_err(|e| CodememError::Storage(e.to_string()))?;
767
768        // Delete the nodes themselves
769        let rows = tx
770            .execute(
771                "DELETE FROM graph_nodes WHERE id LIKE ?1",
772                params![like_pattern],
773            )
774            .map_err(|e| CodememError::Storage(e.to_string()))?;
775
776        tx.commit()
777            .map_err(|e| CodememError::Storage(e.to_string()))?;
778
779        Ok(rows)
780    }
781}
782
783#[cfg(test)]
784#[path = "tests/queries_tests.rs"]
785mod tests;