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    /// List memories that contain a specific tag, optionally scoped to a namespace.
367    /// Uses `json_each` for proper JSON array querying instead of LIKE patterns.
368    pub fn list_memories_by_tag(
369        &self,
370        tag: &str,
371        namespace: Option<&str>,
372        limit: usize,
373    ) -> Result<Vec<codemem_core::MemoryNode>, CodememError> {
374        let conn = self.conn()?;
375
376        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(ns) =
377            namespace
378        {
379            (
380                "SELECT m.id, m.content, m.memory_type, m.importance, m.confidence, m.access_count, \
381                 m.content_hash, m.tags, m.metadata, m.namespace, m.session_id, m.created_at, m.updated_at, m.last_accessed_at \
382                 FROM memories m, json_each(m.tags) AS jt \
383                 WHERE jt.value = ?1 AND m.namespace = ?2 \
384                 ORDER BY m.created_at DESC LIMIT ?3"
385                    .to_string(),
386                vec![
387                    Box::new(tag.to_string()) as Box<dyn rusqlite::types::ToSql>,
388                    Box::new(ns.to_string()),
389                    Box::new(limit as i64),
390                ],
391            )
392        } else {
393            (
394                "SELECT m.id, m.content, m.memory_type, m.importance, m.confidence, m.access_count, \
395                 m.content_hash, m.tags, m.metadata, m.namespace, m.session_id, m.created_at, m.updated_at, m.last_accessed_at \
396                 FROM memories m, json_each(m.tags) AS jt \
397                 WHERE jt.value = ?1 \
398                 ORDER BY m.created_at DESC LIMIT ?2"
399                    .to_string(),
400                vec![
401                    Box::new(tag.to_string()) as Box<dyn rusqlite::types::ToSql>,
402                    Box::new(limit as i64),
403                ],
404            )
405        };
406
407        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
408            params_vec.iter().map(|b| &**b).collect();
409
410        let mut stmt = conn
411            .prepare(&sql)
412            .map_err(|e| CodememError::Storage(e.to_string()))?;
413
414        let rows = stmt
415            .query_map(params_refs.as_slice(), |row| {
416                let created_ts: i64 = row.get(11)?;
417                let updated_ts: i64 = row.get(12)?;
418                let accessed_ts: i64 = row.get(13)?;
419                let tags_json: String = row.get(7)?;
420                let metadata_json: String = row.get(8)?;
421                let memory_type_str: String = row.get(2)?;
422
423                Ok(codemem_core::MemoryNode {
424                    id: row.get(0)?,
425                    content: row.get(1)?,
426                    memory_type: memory_type_str
427                        .parse()
428                        .unwrap_or(codemem_core::MemoryType::Context),
429                    importance: row.get(3)?,
430                    confidence: row.get(4)?,
431                    access_count: row.get::<_, i64>(5).unwrap_or(0) as u32,
432                    content_hash: row.get(6)?,
433                    tags: serde_json::from_str(&tags_json).unwrap_or_default(),
434                    metadata: serde_json::from_str(&metadata_json).unwrap_or_default(),
435                    namespace: row.get(9)?,
436                    session_id: row.get(10)?,
437                    created_at: chrono::DateTime::from_timestamp(created_ts, 0)
438                        .unwrap_or_default()
439                        .with_timezone(&chrono::Utc),
440                    updated_at: chrono::DateTime::from_timestamp(updated_ts, 0)
441                        .unwrap_or_default()
442                        .with_timezone(&chrono::Utc),
443                    last_accessed_at: chrono::DateTime::from_timestamp(accessed_ts, 0)
444                        .unwrap_or_default()
445                        .with_timezone(&chrono::Utc),
446                })
447            })
448            .map_err(|e| CodememError::Storage(e.to_string()))?
449            .collect::<Result<Vec<_>, _>>()
450            .map_err(|e| CodememError::Storage(e.to_string()))?;
451
452        Ok(rows)
453    }
454
455    // ── Session Management ─────────────────────────────────────────────
456
457    /// Start a new session.
458    pub fn start_session(&self, id: &str, namespace: Option<&str>) -> Result<(), CodememError> {
459        let conn = self.conn()?;
460        let now = chrono::Utc::now().timestamp();
461        conn.execute(
462            "INSERT OR IGNORE INTO sessions (id, namespace, started_at) VALUES (?1, ?2, ?3)",
463            params![id, namespace, now],
464        )
465        .map_err(|e| CodememError::Storage(e.to_string()))?;
466        Ok(())
467    }
468
469    /// End a session by setting ended_at and optionally a summary.
470    pub fn end_session(&self, id: &str, summary: Option<&str>) -> Result<(), CodememError> {
471        let conn = self.conn()?;
472        let now = chrono::Utc::now().timestamp();
473        conn.execute(
474            "UPDATE sessions SET ended_at = ?1, summary = ?2 WHERE id = ?3",
475            params![now, summary, id],
476        )
477        .map_err(|e| CodememError::Storage(e.to_string()))?;
478        Ok(())
479    }
480
481    /// List sessions, optionally filtered by namespace.
482    pub fn list_sessions(&self, namespace: Option<&str>) -> Result<Vec<Session>, CodememError> {
483        self.list_sessions_with_limit(namespace, usize::MAX)
484    }
485
486    // ── Session Activity Tracking ─────────────────────────────────
487
488    /// Record a session activity event.
489    pub fn record_session_activity(
490        &self,
491        session_id: &str,
492        tool_name: &str,
493        file_path: Option<&str>,
494        directory: Option<&str>,
495        pattern: Option<&str>,
496    ) -> Result<(), CodememError> {
497        let conn = self.conn()?;
498        let now = chrono::Utc::now().timestamp();
499        conn.execute(
500            "INSERT INTO session_activity (session_id, tool_name, file_path, directory, pattern, created_at)
501             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
502            params![session_id, tool_name, file_path, directory, pattern, now],
503        )
504        .map_err(|e| CodememError::Storage(e.to_string()))?;
505        Ok(())
506    }
507
508    /// Get a summary of session activity counts using a single query with conditional aggregation.
509    pub fn get_session_activity_summary(
510        &self,
511        session_id: &str,
512    ) -> Result<codemem_core::SessionActivitySummary, CodememError> {
513        let conn = self.conn()?;
514
515        let (files_read, files_edited, searches, total_actions) = conn
516            .query_row(
517                "SELECT
518                     COUNT(DISTINCT CASE WHEN tool_name = 'Read' AND file_path IS NOT NULL THEN file_path END),
519                     COUNT(DISTINCT CASE WHEN tool_name IN ('Edit', 'Write') AND file_path IS NOT NULL THEN file_path END),
520                     SUM(CASE WHEN tool_name IN ('Grep', 'Glob') THEN 1 ELSE 0 END),
521                     COUNT(*)
522                 FROM session_activity
523                 WHERE session_id = ?1",
524                params![session_id],
525                |row| {
526                    Ok((
527                        row.get::<_, i64>(0)?,
528                        row.get::<_, i64>(1)?,
529                        row.get::<_, i64>(2)?,
530                        row.get::<_, i64>(3)?,
531                    ))
532                },
533            )
534            .map_err(|e| CodememError::Storage(e.to_string()))?;
535
536        Ok(codemem_core::SessionActivitySummary {
537            files_read: files_read as usize,
538            files_edited: files_edited as usize,
539            searches: searches as usize,
540            total_actions: total_actions as usize,
541        })
542    }
543
544    /// Get the most active directories in a session.
545    pub fn get_session_hot_directories(
546        &self,
547        session_id: &str,
548        limit: usize,
549    ) -> Result<Vec<(String, usize)>, CodememError> {
550        let conn = self.conn()?;
551        let mut stmt = conn
552            .prepare(
553                "SELECT directory, COUNT(*) AS cnt FROM session_activity
554                 WHERE session_id = ?1 AND directory IS NOT NULL
555                 GROUP BY directory ORDER BY cnt DESC LIMIT ?2",
556            )
557            .map_err(|e| CodememError::Storage(e.to_string()))?;
558
559        let rows = stmt
560            .query_map(params![session_id, limit as i64], |row| {
561                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
562            })
563            .map_err(|e| CodememError::Storage(e.to_string()))?
564            .collect::<Result<Vec<_>, _>>()
565            .map_err(|e| CodememError::Storage(e.to_string()))?;
566
567        Ok(rows
568            .into_iter()
569            .map(|(dir, cnt)| (dir, cnt as usize))
570            .collect())
571    }
572
573    /// Check whether an auto-insight dedup tag exists for a session.
574    /// Uses `json_extract` with proper parameter binding instead of LIKE on JSON.
575    pub fn has_auto_insight(
576        &self,
577        session_id: &str,
578        dedup_tag: &str,
579    ) -> Result<bool, CodememError> {
580        let conn = self.conn()?;
581        let count: i64 = conn
582            .query_row(
583                "SELECT COUNT(*) FROM memories
584                 WHERE json_extract(metadata, '$.session_id') = ?1
585                   AND json_extract(metadata, '$.auto_insight_tag') = ?2",
586                params![session_id, dedup_tag],
587                |row| row.get(0),
588            )
589            .map_err(|e| CodememError::Storage(e.to_string()))?;
590        Ok(count > 0)
591    }
592
593    /// Count Read events in a directory during a session.
594    pub fn count_directory_reads(
595        &self,
596        session_id: &str,
597        directory: &str,
598    ) -> Result<usize, CodememError> {
599        let conn = self.conn()?;
600        let count: i64 = conn
601            .query_row(
602                "SELECT COUNT(*) FROM session_activity
603                 WHERE session_id = ?1 AND tool_name = 'Read' AND directory = ?2",
604                params![session_id, directory],
605                |row| row.get(0),
606            )
607            .map_err(|e| CodememError::Storage(e.to_string()))?;
608        Ok(count as usize)
609    }
610
611    /// Check if a file was read in the current session.
612    pub fn was_file_read_in_session(
613        &self,
614        session_id: &str,
615        file_path: &str,
616    ) -> Result<bool, CodememError> {
617        let conn = self.conn()?;
618        let count: i64 = conn
619            .query_row(
620                "SELECT COUNT(*) FROM session_activity
621                 WHERE session_id = ?1 AND tool_name = 'Read' AND file_path = ?2",
622                params![session_id, file_path],
623                |row| row.get(0),
624            )
625            .map_err(|e| CodememError::Storage(e.to_string()))?;
626        Ok(count > 0)
627    }
628
629    /// Count how many times a search pattern was used in a session.
630    pub fn count_search_pattern_in_session(
631        &self,
632        session_id: &str,
633        pattern: &str,
634    ) -> Result<usize, CodememError> {
635        let conn = self.conn()?;
636        let count: i64 = conn
637            .query_row(
638                "SELECT COUNT(*) FROM session_activity
639                 WHERE session_id = ?1 AND tool_name IN ('Grep', 'Glob') AND pattern = ?2",
640                params![session_id, pattern],
641                |row| row.get(0),
642            )
643            .map_err(|e| CodememError::Storage(e.to_string()))?;
644        Ok(count as usize)
645    }
646
647    /// List sessions with a limit.
648    pub(crate) fn list_sessions_with_limit(
649        &self,
650        namespace: Option<&str>,
651        limit: usize,
652    ) -> Result<Vec<Session>, CodememError> {
653        let conn = self.conn()?;
654        let sql_with_ns = "SELECT s.id, s.namespace, s.started_at, s.ended_at, (SELECT COUNT(*) FROM memories m WHERE m.session_id = s.id) as memory_count, s.summary FROM sessions s WHERE s.namespace = ?1 ORDER BY s.started_at DESC LIMIT ?2";
655        let sql_all = "SELECT s.id, s.namespace, s.started_at, s.ended_at, (SELECT COUNT(*) FROM memories m WHERE m.session_id = s.id) as memory_count, s.summary FROM sessions s ORDER BY s.started_at DESC LIMIT ?1";
656
657        let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Session> {
658            let started_ts: i64 = row.get(2)?;
659            let ended_ts: Option<i64> = row.get(3)?;
660            Ok(Session {
661                id: row.get(0)?,
662                namespace: row.get(1)?,
663                started_at: chrono::DateTime::from_timestamp(started_ts, 0)
664                    .unwrap_or_default()
665                    .with_timezone(&chrono::Utc),
666                ended_at: ended_ts.and_then(|ts| {
667                    chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&chrono::Utc))
668                }),
669                memory_count: row.get::<_, i64>(4).unwrap_or(0) as u32,
670                summary: row.get(5)?,
671            })
672        };
673
674        if let Some(ns) = namespace {
675            let mut stmt = conn
676                .prepare(sql_with_ns)
677                .map_err(|e| CodememError::Storage(e.to_string()))?;
678            let rows = stmt
679                .query_map(params![ns, limit as i64], map_row)
680                .map_err(|e| CodememError::Storage(e.to_string()))?;
681            rows.collect::<Result<Vec<_>, _>>()
682                .map_err(|e| CodememError::Storage(e.to_string()))
683        } else {
684            let mut stmt = conn
685                .prepare(sql_all)
686                .map_err(|e| CodememError::Storage(e.to_string()))?;
687            let rows = stmt
688                .query_map(params![limit as i64], map_row)
689                .map_err(|e| CodememError::Storage(e.to_string()))?;
690            rows.collect::<Result<Vec<_>, _>>()
691                .map_err(|e| CodememError::Storage(e.to_string()))
692        }
693    }
694
695    // ── Tag-based Queries ─────────────────────────────────────────
696
697    /// Find memory IDs whose tags JSON array contains the given tag value.
698    /// Optionally scoped to a namespace. Excludes the given `exclude_id`.
699    /// Returns at most 50 results ordered by creation time (most recent siblings first).
700    pub fn find_memory_ids_by_tag(
701        &self,
702        tag: &str,
703        namespace: Option<&str>,
704        exclude_id: &str,
705    ) -> Result<Vec<String>, CodememError> {
706        let conn = self.conn()?;
707
708        // Use json_each() for exact tag matching instead of LIKE (safe against %, _, " in tags).
709        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
710            if let Some(ns) = namespace {
711                (
712                    "SELECT DISTINCT m.id FROM memories m, json_each(m.tags) t \
713                 WHERE t.value = ?1 AND m.namespace IS ?2 AND m.id != ?3 \
714                 ORDER BY m.created_at DESC LIMIT 50"
715                        .to_string(),
716                    vec![
717                        Box::new(tag.to_string()) as Box<dyn rusqlite::types::ToSql>,
718                        Box::new(ns.to_string()),
719                        Box::new(exclude_id.to_string()),
720                    ],
721                )
722            } else {
723                (
724                    "SELECT DISTINCT m.id FROM memories m, json_each(m.tags) t \
725                 WHERE t.value = ?1 AND m.namespace IS NULL AND m.id != ?2 \
726                 ORDER BY m.created_at DESC LIMIT 50"
727                        .to_string(),
728                    vec![
729                        Box::new(tag.to_string()) as Box<dyn rusqlite::types::ToSql>,
730                        Box::new(exclude_id.to_string()),
731                    ],
732                )
733            };
734
735        let refs: Vec<&dyn rusqlite::types::ToSql> =
736            params_vec.iter().map(|p| p.as_ref()).collect();
737
738        let mut stmt = conn
739            .prepare(&sql)
740            .map_err(|e| CodememError::Storage(e.to_string()))?;
741
742        let ids = stmt
743            .query_map(refs.as_slice(), |row| row.get(0))
744            .map_err(|e| CodememError::Storage(e.to_string()))?
745            .collect::<Result<Vec<String>, _>>()
746            .map_err(|e| CodememError::Storage(e.to_string()))?;
747
748        Ok(ids)
749    }
750
751    // ── Graph Cleanup ───────────────────────────────────────────────
752
753    /// Delete all graph nodes, their edges, and their embeddings where the
754    /// node ID starts with the given prefix. Returns count of nodes deleted.
755    /// Wrapped in a transaction so all three DELETEs are atomic.
756    pub fn delete_graph_nodes_by_prefix(&self, prefix: &str) -> Result<usize, CodememError> {
757        let conn = self.conn()?;
758        let like_pattern = format!("{prefix}%");
759
760        let tx = conn
761            .unchecked_transaction()
762            .map_err(|e| CodememError::Storage(e.to_string()))?;
763
764        // Delete edges where src or dst matches prefix
765        tx.execute(
766            "DELETE FROM graph_edges WHERE src LIKE ?1 OR dst LIKE ?1",
767            params![like_pattern],
768        )
769        .map_err(|e| CodememError::Storage(e.to_string()))?;
770
771        // Delete embeddings for matching nodes
772        tx.execute(
773            "DELETE FROM memory_embeddings WHERE memory_id LIKE ?1",
774            params![like_pattern],
775        )
776        .map_err(|e| CodememError::Storage(e.to_string()))?;
777
778        // Delete the nodes themselves
779        let rows = tx
780            .execute(
781                "DELETE FROM graph_nodes WHERE id LIKE ?1",
782                params![like_pattern],
783            )
784            .map_err(|e| CodememError::Storage(e.to_string()))?;
785
786        tx.commit()
787            .map_err(|e| CodememError::Storage(e.to_string()))?;
788
789        Ok(rows)
790    }
791}
792
793#[cfg(test)]
794#[path = "tests/queries_tests.rs"]
795mod tests;