1use codemem_core::{
6 CodememError, Edge, GraphNode, MemoryNode, MemoryType, NodeKind, RelationshipType,
7};
8use rusqlite::{params, Connection, OptionalExtension};
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11use std::path::Path;
12
13const SCHEMA: &str = include_str!("schema.sql");
14
15pub struct Storage {
17 conn: Connection,
18}
19
20impl Storage {
21 pub fn open(path: &Path) -> Result<Self, CodememError> {
23 let conn = Connection::open(path).map_err(|e| CodememError::Storage(e.to_string()))?;
24
25 conn.pragma_update(None, "journal_mode", "WAL")
27 .map_err(|e| CodememError::Storage(e.to_string()))?;
28 conn.pragma_update(None, "cache_size", -64000i64)
30 .map_err(|e| CodememError::Storage(e.to_string()))?;
31 conn.pragma_update(None, "foreign_keys", "ON")
33 .map_err(|e| CodememError::Storage(e.to_string()))?;
34 conn.pragma_update(None, "synchronous", "NORMAL")
36 .map_err(|e| CodememError::Storage(e.to_string()))?;
37 conn.pragma_update(None, "mmap_size", 268435456i64)
39 .map_err(|e| CodememError::Storage(e.to_string()))?;
40 conn.pragma_update(None, "temp_store", "MEMORY")
42 .map_err(|e| CodememError::Storage(e.to_string()))?;
43 conn.busy_timeout(std::time::Duration::from_secs(5))
45 .map_err(|e| CodememError::Storage(e.to_string()))?;
46
47 conn.execute_batch(SCHEMA)
49 .map_err(|e| CodememError::Storage(e.to_string()))?;
50
51 Ok(Self { conn })
52 }
53
54 pub fn open_in_memory() -> Result<Self, CodememError> {
56 let conn =
57 Connection::open_in_memory().map_err(|e| CodememError::Storage(e.to_string()))?;
58 conn.pragma_update(None, "foreign_keys", "ON")
59 .map_err(|e| CodememError::Storage(e.to_string()))?;
60 conn.execute_batch(SCHEMA)
61 .map_err(|e| CodememError::Storage(e.to_string()))?;
62 Ok(Self { conn })
63 }
64
65 pub fn content_hash(content: &str) -> String {
67 let mut hasher = Sha256::new();
68 hasher.update(content.as_bytes());
69 format!("{:x}", hasher.finalize())
70 }
71
72 pub fn insert_memory(&self, memory: &MemoryNode) -> Result<(), CodememError> {
76 let existing: Option<String> = self
78 .conn
79 .query_row(
80 "SELECT id FROM memories WHERE content_hash = ?1",
81 params![memory.content_hash],
82 |row| row.get(0),
83 )
84 .optional()
85 .map_err(|e| CodememError::Storage(e.to_string()))?;
86
87 if let Some(_existing_id) = existing {
88 return Err(CodememError::Duplicate(memory.content_hash.clone()));
89 }
90
91 let tags_json = serde_json::to_string(&memory.tags)?;
92 let metadata_json = serde_json::to_string(&memory.metadata)?;
93
94 self.conn
95 .execute(
96 "INSERT INTO memories (id, content, memory_type, importance, confidence, access_count, content_hash, tags, metadata, namespace, created_at, updated_at, last_accessed_at)
97 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
98 params![
99 memory.id,
100 memory.content,
101 memory.memory_type.to_string(),
102 memory.importance,
103 memory.confidence,
104 memory.access_count,
105 memory.content_hash,
106 tags_json,
107 metadata_json,
108 memory.namespace,
109 memory.created_at.timestamp(),
110 memory.updated_at.timestamp(),
111 memory.last_accessed_at.timestamp(),
112 ],
113 )
114 .map_err(|e| CodememError::Storage(e.to_string()))?;
115
116 Ok(())
117 }
118
119 pub fn get_memory(&self, id: &str) -> Result<Option<MemoryNode>, CodememError> {
121 let updated = self
123 .conn
124 .execute(
125 "UPDATE memories SET access_count = access_count + 1, last_accessed_at = ?1 WHERE id = ?2",
126 params![chrono::Utc::now().timestamp(), id],
127 )
128 .map_err(|e| CodememError::Storage(e.to_string()))?;
129
130 if updated == 0 {
131 return Ok(None);
132 }
133
134 let result = self
135 .conn
136 .query_row(
137 "SELECT id, content, memory_type, importance, confidence, access_count, content_hash, tags, metadata, namespace, created_at, updated_at, last_accessed_at FROM memories WHERE id = ?1",
138 params![id],
139 |row| {
140 Ok(MemoryRow {
141 id: row.get(0)?,
142 content: row.get(1)?,
143 memory_type: row.get(2)?,
144 importance: row.get(3)?,
145 confidence: row.get(4)?,
146 access_count: row.get(5)?,
147 content_hash: row.get(6)?,
148 tags: row.get(7)?,
149 metadata: row.get(8)?,
150 namespace: row.get(9)?,
151 created_at: row.get(10)?,
152 updated_at: row.get(11)?,
153 last_accessed_at: row.get(12)?,
154 })
155 },
156 )
157 .optional()
158 .map_err(|e| CodememError::Storage(e.to_string()))?;
159
160 match result {
161 Some(row) => Ok(Some(row.into_memory_node()?)),
162 None => Ok(None),
163 }
164 }
165
166 pub fn update_memory(
168 &self,
169 id: &str,
170 content: &str,
171 importance: Option<f64>,
172 ) -> Result<(), CodememError> {
173 let hash = Self::content_hash(content);
174 let now = chrono::Utc::now().timestamp();
175
176 let mut sql =
177 "UPDATE memories SET content = ?1, content_hash = ?2, updated_at = ?3".to_string();
178 if importance.is_some() {
179 sql.push_str(", importance = ?4");
180 }
181 sql.push_str(" WHERE id = ?5");
182
183 if let Some(imp) = importance {
184 self.conn
185 .execute(&sql, params![content, hash, now, imp, id])
186 .map_err(|e| CodememError::Storage(e.to_string()))?;
187 } else {
188 self.conn
190 .execute(
191 "UPDATE memories SET content = ?1, content_hash = ?2, updated_at = ?3 WHERE id = ?4",
192 params![content, hash, now, id],
193 )
194 .map_err(|e| CodememError::Storage(e.to_string()))?;
195 }
196
197 Ok(())
198 }
199
200 pub fn delete_memory(&self, id: &str) -> Result<bool, CodememError> {
202 let rows = self
203 .conn
204 .execute("DELETE FROM memories WHERE id = ?1", params![id])
205 .map_err(|e| CodememError::Storage(e.to_string()))?;
206 Ok(rows > 0)
207 }
208
209 pub fn list_memory_ids(&self) -> Result<Vec<String>, CodememError> {
211 let mut stmt = self
212 .conn
213 .prepare("SELECT id FROM memories ORDER BY created_at DESC")
214 .map_err(|e| CodememError::Storage(e.to_string()))?;
215
216 let ids = stmt
217 .query_map([], |row| row.get(0))
218 .map_err(|e| CodememError::Storage(e.to_string()))?
219 .collect::<Result<Vec<String>, _>>()
220 .map_err(|e| CodememError::Storage(e.to_string()))?;
221
222 Ok(ids)
223 }
224
225 pub fn list_memory_ids_for_namespace(
227 &self,
228 namespace: &str,
229 ) -> Result<Vec<String>, CodememError> {
230 let mut stmt = self
231 .conn
232 .prepare("SELECT id FROM memories WHERE namespace = ?1 ORDER BY created_at DESC")
233 .map_err(|e| CodememError::Storage(e.to_string()))?;
234
235 let ids = stmt
236 .query_map(params![namespace], |row| row.get(0))
237 .map_err(|e| CodememError::Storage(e.to_string()))?
238 .collect::<Result<Vec<String>, _>>()
239 .map_err(|e| CodememError::Storage(e.to_string()))?;
240
241 Ok(ids)
242 }
243
244 pub fn list_namespaces(&self) -> Result<Vec<String>, CodememError> {
246 let mut stmt = self
247 .conn
248 .prepare(
249 "SELECT DISTINCT namespace FROM (
250 SELECT namespace FROM memories WHERE namespace IS NOT NULL
251 UNION
252 SELECT namespace FROM graph_nodes WHERE namespace IS NOT NULL
253 ) ORDER BY namespace",
254 )
255 .map_err(|e| CodememError::Storage(e.to_string()))?;
256
257 let namespaces = stmt
258 .query_map([], |row| row.get(0))
259 .map_err(|e| CodememError::Storage(e.to_string()))?
260 .collect::<Result<Vec<String>, _>>()
261 .map_err(|e| CodememError::Storage(e.to_string()))?;
262
263 Ok(namespaces)
264 }
265
266 pub fn memory_count(&self) -> Result<usize, CodememError> {
268 let count: i64 = self
269 .conn
270 .query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))
271 .map_err(|e| CodememError::Storage(e.to_string()))?;
272 Ok(count as usize)
273 }
274
275 pub fn store_embedding(&self, memory_id: &str, embedding: &[f32]) -> Result<(), CodememError> {
279 let blob: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
280
281 self.conn
282 .execute(
283 "INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding) VALUES (?1, ?2)",
284 params![memory_id, blob],
285 )
286 .map_err(|e| CodememError::Storage(e.to_string()))?;
287
288 Ok(())
289 }
290
291 pub fn get_embedding(&self, memory_id: &str) -> Result<Option<Vec<f32>>, CodememError> {
293 let blob: Option<Vec<u8>> = self
294 .conn
295 .query_row(
296 "SELECT embedding FROM memory_embeddings WHERE memory_id = ?1",
297 params![memory_id],
298 |row| row.get(0),
299 )
300 .optional()
301 .map_err(|e| CodememError::Storage(e.to_string()))?;
302
303 match blob {
304 Some(bytes) => {
305 let floats: Vec<f32> = bytes
306 .chunks_exact(4)
307 .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
308 .collect();
309 Ok(Some(floats))
310 }
311 None => Ok(None),
312 }
313 }
314
315 pub fn insert_graph_node(&self, node: &GraphNode) -> Result<(), CodememError> {
319 let payload_json = serde_json::to_string(&node.payload)?;
320
321 self.conn
322 .execute(
323 "INSERT OR REPLACE INTO graph_nodes (id, kind, label, payload, centrality, memory_id, namespace)
324 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
325 params![
326 node.id,
327 node.kind.to_string(),
328 node.label,
329 payload_json,
330 node.centrality,
331 node.memory_id,
332 node.namespace,
333 ],
334 )
335 .map_err(|e| CodememError::Storage(e.to_string()))?;
336
337 Ok(())
338 }
339
340 pub fn get_graph_node(&self, id: &str) -> Result<Option<GraphNode>, CodememError> {
342 self.conn
343 .query_row(
344 "SELECT id, kind, label, payload, centrality, memory_id, namespace FROM graph_nodes WHERE id = ?1",
345 params![id],
346 |row| {
347 let kind_str: String = row.get(1)?;
348 let payload_str: String = row.get(3)?;
349 Ok((
350 row.get::<_, String>(0)?,
351 kind_str,
352 row.get::<_, String>(2)?,
353 payload_str,
354 row.get::<_, f64>(4)?,
355 row.get::<_, Option<String>>(5)?,
356 row.get::<_, Option<String>>(6)?,
357 ))
358 },
359 )
360 .optional()
361 .map_err(|e| CodememError::Storage(e.to_string()))?
362 .map(|(id, kind_str, label, payload_str, centrality, memory_id, namespace)| {
363 let kind: NodeKind = kind_str.parse().map_err(|e: CodememError| CodememError::Storage(e.to_string()))?;
364 let payload: HashMap<String, serde_json::Value> =
365 serde_json::from_str(&payload_str).unwrap_or_default();
366 Ok(GraphNode {
367 id,
368 kind,
369 label,
370 payload,
371 centrality,
372 memory_id,
373 namespace,
374 })
375 })
376 .transpose()
377 }
378
379 pub fn delete_graph_node(&self, id: &str) -> Result<bool, CodememError> {
381 let rows = self
382 .conn
383 .execute("DELETE FROM graph_nodes WHERE id = ?1", params![id])
384 .map_err(|e| CodememError::Storage(e.to_string()))?;
385 Ok(rows > 0)
386 }
387
388 pub fn all_graph_nodes(&self) -> Result<Vec<GraphNode>, CodememError> {
390 let mut stmt = self
391 .conn
392 .prepare("SELECT id, kind, label, payload, centrality, memory_id, namespace FROM graph_nodes")
393 .map_err(|e| CodememError::Storage(e.to_string()))?;
394
395 let nodes = stmt
396 .query_map([], |row| {
397 let kind_str: String = row.get(1)?;
398 let payload_str: String = row.get(3)?;
399 Ok((
400 row.get::<_, String>(0)?,
401 kind_str,
402 row.get::<_, String>(2)?,
403 payload_str,
404 row.get::<_, f64>(4)?,
405 row.get::<_, Option<String>>(5)?,
406 row.get::<_, Option<String>>(6)?,
407 ))
408 })
409 .map_err(|e| CodememError::Storage(e.to_string()))?
410 .filter_map(|r| r.ok())
411 .filter_map(
412 |(id, kind_str, label, payload_str, centrality, memory_id, namespace)| {
413 let kind: NodeKind = kind_str.parse().ok()?;
414 let payload: HashMap<String, serde_json::Value> =
415 serde_json::from_str(&payload_str).unwrap_or_default();
416 Some(GraphNode {
417 id,
418 kind,
419 label,
420 payload,
421 centrality,
422 memory_id,
423 namespace,
424 })
425 },
426 )
427 .collect();
428
429 Ok(nodes)
430 }
431
432 pub fn insert_graph_edge(&self, edge: &Edge) -> Result<(), CodememError> {
436 let props_json = serde_json::to_string(&edge.properties)?;
437
438 self.conn
439 .execute(
440 "INSERT OR REPLACE INTO graph_edges (id, src, dst, relationship, weight, properties, created_at)
441 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
442 params![
443 edge.id,
444 edge.src,
445 edge.dst,
446 edge.relationship.to_string(),
447 edge.weight,
448 props_json,
449 edge.created_at.timestamp(),
450 ],
451 )
452 .map_err(|e| CodememError::Storage(e.to_string()))?;
453
454 Ok(())
455 }
456
457 pub fn get_edges_for_node(&self, node_id: &str) -> Result<Vec<Edge>, CodememError> {
459 let mut stmt = self
460 .conn
461 .prepare(
462 "SELECT id, src, dst, relationship, weight, properties, created_at FROM graph_edges WHERE src = ?1 OR dst = ?1",
463 )
464 .map_err(|e| CodememError::Storage(e.to_string()))?;
465
466 let edges = stmt
467 .query_map(params![node_id], |row| {
468 let rel_str: String = row.get(3)?;
469 let props_str: String = row.get(5)?;
470 let created_ts: i64 = row.get(6)?;
471 Ok((
472 row.get::<_, String>(0)?,
473 row.get::<_, String>(1)?,
474 row.get::<_, String>(2)?,
475 rel_str,
476 row.get::<_, f64>(4)?,
477 props_str,
478 created_ts,
479 ))
480 })
481 .map_err(|e| CodememError::Storage(e.to_string()))?
482 .filter_map(|r| r.ok())
483 .filter_map(|(id, src, dst, rel_str, weight, props_str, created_ts)| {
484 let relationship: RelationshipType = rel_str.parse().ok()?;
485 let properties: HashMap<String, serde_json::Value> =
486 serde_json::from_str(&props_str).unwrap_or_default();
487 let created_at =
488 chrono::DateTime::from_timestamp(created_ts, 0)?.with_timezone(&chrono::Utc);
489 Some(Edge {
490 id,
491 src,
492 dst,
493 relationship,
494 weight,
495 properties,
496 created_at,
497 })
498 })
499 .collect();
500
501 Ok(edges)
502 }
503
504 pub fn all_graph_edges(&self) -> Result<Vec<Edge>, CodememError> {
506 let mut stmt = self
507 .conn
508 .prepare("SELECT id, src, dst, relationship, weight, properties, created_at FROM graph_edges")
509 .map_err(|e| CodememError::Storage(e.to_string()))?;
510
511 let edges = stmt
512 .query_map([], |row| {
513 let rel_str: String = row.get(3)?;
514 let props_str: String = row.get(5)?;
515 let created_ts: i64 = row.get(6)?;
516 Ok((
517 row.get::<_, String>(0)?,
518 row.get::<_, String>(1)?,
519 row.get::<_, String>(2)?,
520 rel_str,
521 row.get::<_, f64>(4)?,
522 props_str,
523 created_ts,
524 ))
525 })
526 .map_err(|e| CodememError::Storage(e.to_string()))?
527 .filter_map(|r| r.ok())
528 .filter_map(|(id, src, dst, rel_str, weight, props_str, created_ts)| {
529 let relationship: RelationshipType = rel_str.parse().ok()?;
530 let properties: HashMap<String, serde_json::Value> =
531 serde_json::from_str(&props_str).unwrap_or_default();
532 let created_at =
533 chrono::DateTime::from_timestamp(created_ts, 0)?.with_timezone(&chrono::Utc);
534 Some(Edge {
535 id,
536 src,
537 dst,
538 relationship,
539 weight,
540 properties,
541 created_at,
542 })
543 })
544 .collect();
545
546 Ok(edges)
547 }
548
549 pub fn delete_graph_edges_for_node(&self, node_id: &str) -> Result<usize, CodememError> {
551 let rows = self
552 .conn
553 .execute(
554 "DELETE FROM graph_edges WHERE src = ?1 OR dst = ?1",
555 params![node_id],
556 )
557 .map_err(|e| CodememError::Storage(e.to_string()))?;
558 Ok(rows)
559 }
560
561 pub fn graph_edges_for_namespace(&self, namespace: &str) -> Result<Vec<Edge>, CodememError> {
563 let mut stmt = self
564 .conn
565 .prepare(
566 "SELECT e.id, e.src, e.dst, e.relationship, e.weight, e.properties, e.created_at
567 FROM graph_edges e
568 INNER JOIN graph_nodes gs ON e.src = gs.id
569 INNER JOIN graph_nodes gd ON e.dst = gd.id
570 WHERE gs.namespace = ?1 AND gd.namespace = ?1",
571 )
572 .map_err(|e| CodememError::Storage(e.to_string()))?;
573
574 let edges = stmt
575 .query_map(params![namespace], |row| {
576 let rel_str: String = row.get(3)?;
577 let props_str: String = row.get(5)?;
578 let created_ts: i64 = row.get(6)?;
579 Ok((
580 row.get::<_, String>(0)?,
581 row.get::<_, String>(1)?,
582 row.get::<_, String>(2)?,
583 rel_str,
584 row.get::<_, f64>(4)?,
585 props_str,
586 created_ts,
587 ))
588 })
589 .map_err(|e| CodememError::Storage(e.to_string()))?
590 .filter_map(|r| r.ok())
591 .filter_map(|(id, src, dst, rel_str, weight, props_str, created_ts)| {
592 let relationship: RelationshipType = rel_str.parse().ok()?;
593 let properties: HashMap<String, serde_json::Value> =
594 serde_json::from_str(&props_str).unwrap_or_default();
595 let created_at =
596 chrono::DateTime::from_timestamp(created_ts, 0)?.with_timezone(&chrono::Utc);
597 Some(Edge {
598 id,
599 src,
600 dst,
601 relationship,
602 weight,
603 properties,
604 created_at,
605 })
606 })
607 .collect();
608
609 Ok(edges)
610 }
611
612 pub fn delete_graph_edge(&self, id: &str) -> Result<bool, CodememError> {
614 let rows = self
615 .conn
616 .execute("DELETE FROM graph_edges WHERE id = ?1", params![id])
617 .map_err(|e| CodememError::Storage(e.to_string()))?;
618 Ok(rows > 0)
619 }
620
621 pub fn stats(&self) -> Result<StorageStats, CodememError> {
625 let memory_count = self.memory_count()?;
626
627 let embedding_count: i64 = self
628 .conn
629 .query_row("SELECT COUNT(*) FROM memory_embeddings", [], |row| {
630 row.get(0)
631 })
632 .map_err(|e| CodememError::Storage(e.to_string()))?;
633
634 let node_count: i64 = self
635 .conn
636 .query_row("SELECT COUNT(*) FROM graph_nodes", [], |row| row.get(0))
637 .map_err(|e| CodememError::Storage(e.to_string()))?;
638
639 let edge_count: i64 = self
640 .conn
641 .query_row("SELECT COUNT(*) FROM graph_edges", [], |row| row.get(0))
642 .map_err(|e| CodememError::Storage(e.to_string()))?;
643
644 Ok(StorageStats {
645 memory_count,
646 embedding_count: embedding_count as usize,
647 node_count: node_count as usize,
648 edge_count: edge_count as usize,
649 })
650 }
651
652 pub fn insert_consolidation_log(
656 &self,
657 cycle_type: &str,
658 affected_count: usize,
659 ) -> Result<(), CodememError> {
660 let now = chrono::Utc::now().timestamp();
661 self.conn
662 .execute(
663 "INSERT INTO consolidation_log (cycle_type, run_at, affected_count) VALUES (?1, ?2, ?3)",
664 params![cycle_type, now, affected_count as i64],
665 )
666 .map_err(|e| CodememError::Storage(e.to_string()))?;
667 Ok(())
668 }
669
670 pub fn last_consolidation_runs(&self) -> Result<Vec<ConsolidationLogEntry>, CodememError> {
672 let mut stmt = self
673 .conn
674 .prepare(
675 "SELECT cycle_type, run_at, affected_count FROM consolidation_log
676 WHERE id IN (
677 SELECT id FROM consolidation_log c2
678 WHERE c2.cycle_type = consolidation_log.cycle_type
679 ORDER BY run_at DESC LIMIT 1
680 )
681 GROUP BY cycle_type
682 ORDER BY cycle_type",
683 )
684 .map_err(|e| CodememError::Storage(e.to_string()))?;
685
686 let entries = stmt
687 .query_map([], |row| {
688 Ok(ConsolidationLogEntry {
689 cycle_type: row.get(0)?,
690 run_at: row.get(1)?,
691 affected_count: row.get::<_, i64>(2)? as usize,
692 })
693 })
694 .map_err(|e| CodememError::Storage(e.to_string()))?
695 .collect::<Result<Vec<_>, _>>()
696 .map_err(|e| CodememError::Storage(e.to_string()))?;
697
698 Ok(entries)
699 }
700
701 pub fn connection(&self) -> &Connection {
703 &self.conn
704 }
705
706 pub fn get_repeated_searches(
712 &self,
713 min_count: usize,
714 namespace: Option<&str>,
715 ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
716 let sql = if namespace.is_some() {
719 "SELECT json_extract(metadata, '$.pattern') AS pat,
720 COUNT(*) AS cnt,
721 GROUP_CONCAT(id, ',') AS ids
722 FROM memories
723 WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
724 AND pat IS NOT NULL
725 AND namespace = ?1
726 GROUP BY pat
727 HAVING cnt >= ?2
728 ORDER BY cnt DESC"
729 } else {
730 "SELECT json_extract(metadata, '$.pattern') AS pat,
731 COUNT(*) AS cnt,
732 GROUP_CONCAT(id, ',') AS ids
733 FROM memories
734 WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
735 AND pat IS NOT NULL
736 GROUP BY pat
737 HAVING cnt >= ?1
738 ORDER BY cnt DESC"
739 };
740
741 let mut stmt = self
742 .conn
743 .prepare(sql)
744 .map_err(|e| CodememError::Storage(e.to_string()))?;
745
746 let rows = if let Some(ns) = namespace {
747 stmt.query_map(params![ns, min_count as i64], |row| {
748 Ok((
749 row.get::<_, String>(0)?,
750 row.get::<_, i64>(1)?,
751 row.get::<_, String>(2)?,
752 ))
753 })
754 .map_err(|e| CodememError::Storage(e.to_string()))?
755 .collect::<Result<Vec<_>, _>>()
756 .map_err(|e| CodememError::Storage(e.to_string()))?
757 } else {
758 stmt.query_map(params![min_count as i64], |row| {
759 Ok((
760 row.get::<_, String>(0)?,
761 row.get::<_, i64>(1)?,
762 row.get::<_, String>(2)?,
763 ))
764 })
765 .map_err(|e| CodememError::Storage(e.to_string()))?
766 .collect::<Result<Vec<_>, _>>()
767 .map_err(|e| CodememError::Storage(e.to_string()))?
768 };
769
770 Ok(rows
771 .into_iter()
772 .map(|(pat, cnt, ids_str)| {
773 let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
774 (pat, cnt as usize, ids)
775 })
776 .collect())
777 }
778
779 pub fn get_file_hotspots(
783 &self,
784 min_count: usize,
785 namespace: Option<&str>,
786 ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
787 let sql = if namespace.is_some() {
788 "SELECT json_extract(metadata, '$.file_path') AS fp,
789 COUNT(*) AS cnt,
790 GROUP_CONCAT(id, ',') AS ids
791 FROM memories
792 WHERE fp IS NOT NULL
793 AND namespace = ?1
794 GROUP BY fp
795 HAVING cnt >= ?2
796 ORDER BY cnt DESC"
797 } else {
798 "SELECT json_extract(metadata, '$.file_path') AS fp,
799 COUNT(*) AS cnt,
800 GROUP_CONCAT(id, ',') AS ids
801 FROM memories
802 WHERE fp IS NOT NULL
803 GROUP BY fp
804 HAVING cnt >= ?1
805 ORDER BY cnt DESC"
806 };
807
808 let mut stmt = self
809 .conn
810 .prepare(sql)
811 .map_err(|e| CodememError::Storage(e.to_string()))?;
812
813 let rows = if let Some(ns) = namespace {
814 stmt.query_map(params![ns, min_count as i64], |row| {
815 Ok((
816 row.get::<_, String>(0)?,
817 row.get::<_, i64>(1)?,
818 row.get::<_, String>(2)?,
819 ))
820 })
821 .map_err(|e| CodememError::Storage(e.to_string()))?
822 .collect::<Result<Vec<_>, _>>()
823 .map_err(|e| CodememError::Storage(e.to_string()))?
824 } else {
825 stmt.query_map(params![min_count as i64], |row| {
826 Ok((
827 row.get::<_, String>(0)?,
828 row.get::<_, i64>(1)?,
829 row.get::<_, String>(2)?,
830 ))
831 })
832 .map_err(|e| CodememError::Storage(e.to_string()))?
833 .collect::<Result<Vec<_>, _>>()
834 .map_err(|e| CodememError::Storage(e.to_string()))?
835 };
836
837 Ok(rows
838 .into_iter()
839 .map(|(fp, cnt, ids_str)| {
840 let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
841 (fp, cnt as usize, ids)
842 })
843 .collect())
844 }
845
846 pub fn get_tool_usage_stats(
849 &self,
850 namespace: Option<&str>,
851 ) -> Result<HashMap<String, usize>, CodememError> {
852 let sql = if namespace.is_some() {
853 "SELECT json_extract(metadata, '$.tool') AS tool,
854 COUNT(*) AS cnt
855 FROM memories
856 WHERE tool IS NOT NULL
857 AND namespace = ?1
858 GROUP BY tool
859 ORDER BY cnt DESC"
860 } else {
861 "SELECT json_extract(metadata, '$.tool') AS tool,
862 COUNT(*) AS cnt
863 FROM memories
864 WHERE tool IS NOT NULL
865 GROUP BY tool
866 ORDER BY cnt DESC"
867 };
868
869 let mut stmt = self
870 .conn
871 .prepare(sql)
872 .map_err(|e| CodememError::Storage(e.to_string()))?;
873
874 let rows = if let Some(ns) = namespace {
875 stmt.query_map(params![ns], |row| {
876 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
877 })
878 .map_err(|e| CodememError::Storage(e.to_string()))?
879 .collect::<Result<Vec<_>, _>>()
880 .map_err(|e| CodememError::Storage(e.to_string()))?
881 } else {
882 stmt.query_map([], |row| {
883 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
884 })
885 .map_err(|e| CodememError::Storage(e.to_string()))?
886 .collect::<Result<Vec<_>, _>>()
887 .map_err(|e| CodememError::Storage(e.to_string()))?
888 };
889
890 Ok(rows
891 .into_iter()
892 .map(|(tool, cnt)| (tool, cnt as usize))
893 .collect())
894 }
895
896 pub fn get_decision_chains(
899 &self,
900 min_count: usize,
901 namespace: Option<&str>,
902 ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
903 let sql = if namespace.is_some() {
904 "SELECT json_extract(metadata, '$.file_path') AS fp,
905 COUNT(*) AS cnt,
906 GROUP_CONCAT(id, ',') AS ids
907 FROM memories
908 WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
909 AND fp IS NOT NULL
910 AND namespace = ?1
911 GROUP BY fp
912 HAVING cnt >= ?2
913 ORDER BY cnt DESC"
914 } else {
915 "SELECT json_extract(metadata, '$.file_path') AS fp,
916 COUNT(*) AS cnt,
917 GROUP_CONCAT(id, ',') AS ids
918 FROM memories
919 WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
920 AND fp IS NOT NULL
921 GROUP BY fp
922 HAVING cnt >= ?1
923 ORDER BY cnt DESC"
924 };
925
926 let mut stmt = self
927 .conn
928 .prepare(sql)
929 .map_err(|e| CodememError::Storage(e.to_string()))?;
930
931 let rows = if let Some(ns) = namespace {
932 stmt.query_map(params![ns, min_count as i64], |row| {
933 Ok((
934 row.get::<_, String>(0)?,
935 row.get::<_, i64>(1)?,
936 row.get::<_, String>(2)?,
937 ))
938 })
939 .map_err(|e| CodememError::Storage(e.to_string()))?
940 .collect::<Result<Vec<_>, _>>()
941 .map_err(|e| CodememError::Storage(e.to_string()))?
942 } else {
943 stmt.query_map(params![min_count as i64], |row| {
944 Ok((
945 row.get::<_, String>(0)?,
946 row.get::<_, i64>(1)?,
947 row.get::<_, String>(2)?,
948 ))
949 })
950 .map_err(|e| CodememError::Storage(e.to_string()))?
951 .collect::<Result<Vec<_>, _>>()
952 .map_err(|e| CodememError::Storage(e.to_string()))?
953 };
954
955 Ok(rows
956 .into_iter()
957 .map(|(fp, cnt, ids_str)| {
958 let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
959 (fp, cnt as usize, ids)
960 })
961 .collect())
962 }
963
964 pub fn ensure_session_column(&self) -> Result<(), CodememError> {
968 let has_col: bool = self
970 .conn
971 .prepare("SELECT session_id FROM memories LIMIT 0")
972 .is_ok();
973 if !has_col {
974 self.conn
975 .execute_batch("ALTER TABLE memories ADD COLUMN session_id TEXT;")
976 .map_err(|e| CodememError::Storage(e.to_string()))?;
977 }
978 Ok(())
979 }
980
981 pub fn start_session(&self, id: &str, namespace: Option<&str>) -> Result<(), CodememError> {
983 let now = chrono::Utc::now().timestamp();
984 self.conn
985 .execute(
986 "INSERT OR IGNORE INTO sessions (id, namespace, started_at) VALUES (?1, ?2, ?3)",
987 params![id, namespace, now],
988 )
989 .map_err(|e| CodememError::Storage(e.to_string()))?;
990 Ok(())
991 }
992
993 pub fn end_session(&self, id: &str, summary: Option<&str>) -> Result<(), CodememError> {
995 let now = chrono::Utc::now().timestamp();
996 self.conn
997 .execute(
998 "UPDATE sessions SET ended_at = ?1, summary = ?2 WHERE id = ?3",
999 params![now, summary, id],
1000 )
1001 .map_err(|e| CodememError::Storage(e.to_string()))?;
1002 Ok(())
1003 }
1004
1005 pub fn list_sessions(
1007 &self,
1008 namespace: Option<&str>,
1009 ) -> Result<Vec<codemem_core::Session>, CodememError> {
1010 let sql_with_ns = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions WHERE namespace = ?1 ORDER BY started_at DESC";
1011 let sql_all = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions ORDER BY started_at DESC";
1012
1013 let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<codemem_core::Session> {
1014 let started_ts: i64 = row.get(2)?;
1015 let ended_ts: Option<i64> = row.get(3)?;
1016 Ok(codemem_core::Session {
1017 id: row.get(0)?,
1018 namespace: row.get(1)?,
1019 started_at: chrono::DateTime::from_timestamp(started_ts, 0)
1020 .unwrap_or_default()
1021 .with_timezone(&chrono::Utc),
1022 ended_at: ended_ts.and_then(|ts| {
1023 chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&chrono::Utc))
1024 }),
1025 memory_count: row.get::<_, i64>(4).unwrap_or(0) as u32,
1026 summary: row.get(5)?,
1027 })
1028 };
1029
1030 if let Some(ns) = namespace {
1031 let mut stmt = self
1032 .conn
1033 .prepare(sql_with_ns)
1034 .map_err(|e| CodememError::Storage(e.to_string()))?;
1035 let rows = stmt
1036 .query_map(params![ns], map_row)
1037 .map_err(|e| CodememError::Storage(e.to_string()))?;
1038 rows.collect::<Result<Vec<_>, _>>()
1039 .map_err(|e| CodememError::Storage(e.to_string()))
1040 } else {
1041 let mut stmt = self
1042 .conn
1043 .prepare(sql_all)
1044 .map_err(|e| CodememError::Storage(e.to_string()))?;
1045 let rows = stmt
1046 .query_map([], map_row)
1047 .map_err(|e| CodememError::Storage(e.to_string()))?;
1048 rows.collect::<Result<Vec<_>, _>>()
1049 .map_err(|e| CodememError::Storage(e.to_string()))
1050 }
1051 }
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize)]
1056pub struct StorageStats {
1057 pub memory_count: usize,
1058 pub embedding_count: usize,
1059 pub node_count: usize,
1060 pub edge_count: usize,
1061}
1062
1063#[derive(Debug, Clone)]
1065pub struct ConsolidationLogEntry {
1066 pub cycle_type: String,
1067 pub run_at: i64,
1068 pub affected_count: usize,
1069}
1070
1071use serde::{Deserialize, Serialize};
1072
1073struct MemoryRow {
1075 id: String,
1076 content: String,
1077 memory_type: String,
1078 importance: f64,
1079 confidence: f64,
1080 access_count: i64,
1081 content_hash: String,
1082 tags: String,
1083 metadata: String,
1084 namespace: Option<String>,
1085 created_at: i64,
1086 updated_at: i64,
1087 last_accessed_at: i64,
1088}
1089
1090impl MemoryRow {
1091 fn into_memory_node(self) -> Result<MemoryNode, CodememError> {
1092 let memory_type: MemoryType = self.memory_type.parse()?;
1093 let tags: Vec<String> = serde_json::from_str(&self.tags).unwrap_or_default();
1094 let metadata: HashMap<String, serde_json::Value> =
1095 serde_json::from_str(&self.metadata).unwrap_or_default();
1096
1097 let created_at = chrono::DateTime::from_timestamp(self.created_at, 0)
1098 .unwrap_or_default()
1099 .with_timezone(&chrono::Utc);
1100 let updated_at = chrono::DateTime::from_timestamp(self.updated_at, 0)
1101 .unwrap_or_default()
1102 .with_timezone(&chrono::Utc);
1103 let last_accessed_at = chrono::DateTime::from_timestamp(self.last_accessed_at, 0)
1104 .unwrap_or_default()
1105 .with_timezone(&chrono::Utc);
1106
1107 Ok(MemoryNode {
1108 id: self.id,
1109 content: self.content,
1110 memory_type,
1111 importance: self.importance,
1112 confidence: self.confidence,
1113 access_count: self.access_count as u32,
1114 content_hash: self.content_hash,
1115 tags,
1116 metadata,
1117 namespace: self.namespace,
1118 created_at,
1119 updated_at,
1120 last_accessed_at,
1121 })
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128 use chrono::Utc;
1129
1130 fn test_memory() -> MemoryNode {
1131 let now = Utc::now();
1132 let content = "Test memory content";
1133 MemoryNode {
1134 id: uuid::Uuid::new_v4().to_string(),
1135 content: content.to_string(),
1136 memory_type: MemoryType::Context,
1137 importance: 0.7,
1138 confidence: 1.0,
1139 access_count: 0,
1140 content_hash: Storage::content_hash(content),
1141 tags: vec!["test".to_string()],
1142 metadata: HashMap::new(),
1143 namespace: None,
1144 created_at: now,
1145 updated_at: now,
1146 last_accessed_at: now,
1147 }
1148 }
1149
1150 fn test_memory_with_metadata(
1151 content: &str,
1152 tool: &str,
1153 extra: HashMap<String, serde_json::Value>,
1154 ) -> MemoryNode {
1155 let now = Utc::now();
1156 let mut metadata = extra;
1157 metadata.insert(
1158 "tool".to_string(),
1159 serde_json::Value::String(tool.to_string()),
1160 );
1161 MemoryNode {
1162 id: uuid::Uuid::new_v4().to_string(),
1163 content: content.to_string(),
1164 memory_type: MemoryType::Context,
1165 importance: 0.5,
1166 confidence: 1.0,
1167 access_count: 0,
1168 content_hash: Storage::content_hash(content),
1169 tags: vec![],
1170 metadata,
1171 namespace: None,
1172 created_at: now,
1173 updated_at: now,
1174 last_accessed_at: now,
1175 }
1176 }
1177
1178 #[test]
1179 fn insert_and_get_memory() {
1180 let storage = Storage::open_in_memory().unwrap();
1181 let memory = test_memory();
1182 storage.insert_memory(&memory).unwrap();
1183
1184 let retrieved = storage.get_memory(&memory.id).unwrap().unwrap();
1185 assert_eq!(retrieved.id, memory.id);
1186 assert_eq!(retrieved.content, memory.content);
1187 assert_eq!(retrieved.access_count, 1); }
1189
1190 #[test]
1191 fn dedup_by_content_hash() {
1192 let storage = Storage::open_in_memory().unwrap();
1193 let m1 = test_memory();
1194 storage.insert_memory(&m1).unwrap();
1195
1196 let mut m2 = test_memory();
1197 m2.id = uuid::Uuid::new_v4().to_string();
1198 m2.content_hash = m1.content_hash.clone(); assert!(matches!(
1201 storage.insert_memory(&m2),
1202 Err(CodememError::Duplicate(_))
1203 ));
1204 }
1205
1206 #[test]
1207 fn delete_memory() {
1208 let storage = Storage::open_in_memory().unwrap();
1209 let memory = test_memory();
1210 storage.insert_memory(&memory).unwrap();
1211 assert!(storage.delete_memory(&memory.id).unwrap());
1212 assert!(storage.get_memory(&memory.id).unwrap().is_none());
1213 }
1214
1215 #[test]
1216 fn store_and_get_embedding() {
1217 let storage = Storage::open_in_memory().unwrap();
1218 let memory = test_memory();
1219 storage.insert_memory(&memory).unwrap();
1220
1221 let embedding: Vec<f32> = (0..768).map(|i| i as f32 / 768.0).collect();
1222 storage.store_embedding(&memory.id, &embedding).unwrap();
1223
1224 let retrieved = storage.get_embedding(&memory.id).unwrap().unwrap();
1225 assert_eq!(retrieved.len(), 768);
1226 assert!((retrieved[0] - embedding[0]).abs() < f32::EPSILON);
1227 }
1228
1229 #[test]
1230 fn graph_node_crud() {
1231 let storage = Storage::open_in_memory().unwrap();
1232 let node = GraphNode {
1233 id: "file:src/main.rs".to_string(),
1234 kind: NodeKind::File,
1235 label: "src/main.rs".to_string(),
1236 payload: HashMap::new(),
1237 centrality: 0.0,
1238 memory_id: None,
1239 namespace: None,
1240 };
1241
1242 storage.insert_graph_node(&node).unwrap();
1243 let retrieved = storage.get_graph_node(&node.id).unwrap().unwrap();
1244 assert_eq!(retrieved.kind, NodeKind::File);
1245 assert!(storage.delete_graph_node(&node.id).unwrap());
1246 }
1247
1248 #[test]
1249 fn stats() {
1250 let storage = Storage::open_in_memory().unwrap();
1251 let stats = storage.stats().unwrap();
1252 assert_eq!(stats.memory_count, 0);
1253 }
1254
1255 #[test]
1258 fn get_repeated_searches_groups_by_pattern() {
1259 let storage = Storage::open_in_memory().unwrap();
1260
1261 for i in 0..3 {
1263 let mut extra = HashMap::new();
1264 extra.insert(
1265 "pattern".to_string(),
1266 serde_json::Value::String("error".to_string()),
1267 );
1268 let mem =
1269 test_memory_with_metadata(&format!("grep search {i} for error"), "Grep", extra);
1270 storage.insert_memory(&mem).unwrap();
1271 }
1272
1273 let mut extra = HashMap::new();
1275 extra.insert(
1276 "pattern".to_string(),
1277 serde_json::Value::String("*.rs".to_string()),
1278 );
1279 let mem = test_memory_with_metadata("glob search for rs files", "Glob", extra);
1280 storage.insert_memory(&mem).unwrap();
1281
1282 let results = storage.get_repeated_searches(2, None).unwrap();
1284 assert_eq!(results.len(), 1);
1285 assert_eq!(results[0].0, "error");
1286 assert_eq!(results[0].1, 3);
1287 assert_eq!(results[0].2.len(), 3);
1288
1289 let results = storage.get_repeated_searches(1, None).unwrap();
1291 assert_eq!(results.len(), 2);
1292 }
1293
1294 #[test]
1295 fn get_file_hotspots_groups_by_file_path() {
1296 let storage = Storage::open_in_memory().unwrap();
1297
1298 for i in 0..4 {
1300 let mut extra = HashMap::new();
1301 extra.insert(
1302 "file_path".to_string(),
1303 serde_json::Value::String("src/main.rs".to_string()),
1304 );
1305 let mem =
1306 test_memory_with_metadata(&format!("read main.rs attempt {i}"), "Read", extra);
1307 storage.insert_memory(&mem).unwrap();
1308 }
1309
1310 let mut extra = HashMap::new();
1312 extra.insert(
1313 "file_path".to_string(),
1314 serde_json::Value::String("src/lib.rs".to_string()),
1315 );
1316 let mem = test_memory_with_metadata("read lib.rs", "Read", extra);
1317 storage.insert_memory(&mem).unwrap();
1318
1319 let results = storage.get_file_hotspots(3, None).unwrap();
1320 assert_eq!(results.len(), 1);
1321 assert_eq!(results[0].0, "src/main.rs");
1322 assert_eq!(results[0].1, 4);
1323 }
1324
1325 #[test]
1326 fn get_tool_usage_stats_counts_by_tool() {
1327 let storage = Storage::open_in_memory().unwrap();
1328
1329 for i in 0..5 {
1331 let mem = test_memory_with_metadata(&format!("read file {i}"), "Read", HashMap::new());
1332 storage.insert_memory(&mem).unwrap();
1333 }
1334 for i in 0..3 {
1335 let mem =
1336 test_memory_with_metadata(&format!("grep search {i}"), "Grep", HashMap::new());
1337 storage.insert_memory(&mem).unwrap();
1338 }
1339 let mem = test_memory_with_metadata("edit file", "Edit", HashMap::new());
1340 storage.insert_memory(&mem).unwrap();
1341
1342 let stats = storage.get_tool_usage_stats(None).unwrap();
1343 assert_eq!(stats.get("Read"), Some(&5));
1344 assert_eq!(stats.get("Grep"), Some(&3));
1345 assert_eq!(stats.get("Edit"), Some(&1));
1346 }
1347
1348 #[test]
1349 fn get_decision_chains_groups_edits_by_file() {
1350 let storage = Storage::open_in_memory().unwrap();
1351
1352 for i in 0..3 {
1354 let mut extra = HashMap::new();
1355 extra.insert(
1356 "file_path".to_string(),
1357 serde_json::Value::String("src/main.rs".to_string()),
1358 );
1359 let mem = test_memory_with_metadata(&format!("edit main.rs {i}"), "Edit", extra);
1360 storage.insert_memory(&mem).unwrap();
1361 }
1362
1363 let mut extra = HashMap::new();
1365 extra.insert(
1366 "file_path".to_string(),
1367 serde_json::Value::String("src/new.rs".to_string()),
1368 );
1369 let mem = test_memory_with_metadata("write new.rs", "Write", extra);
1370 storage.insert_memory(&mem).unwrap();
1371
1372 let results = storage.get_decision_chains(2, None).unwrap();
1373 assert_eq!(results.len(), 1);
1374 assert_eq!(results[0].0, "src/main.rs");
1375 assert_eq!(results[0].1, 3);
1376 }
1377
1378 #[test]
1379 fn pattern_queries_empty_db() {
1380 let storage = Storage::open_in_memory().unwrap();
1381
1382 let searches = storage.get_repeated_searches(1, None).unwrap();
1383 assert!(searches.is_empty());
1384
1385 let hotspots = storage.get_file_hotspots(1, None).unwrap();
1386 assert!(hotspots.is_empty());
1387
1388 let stats = storage.get_tool_usage_stats(None).unwrap();
1389 assert!(stats.is_empty());
1390
1391 let chains = storage.get_decision_chains(1, None).unwrap();
1392 assert!(chains.is_empty());
1393 }
1394
1395 #[test]
1396 fn pattern_queries_with_namespace_filter() {
1397 let storage = Storage::open_in_memory().unwrap();
1398
1399 for i in 0..3 {
1401 let mut extra = HashMap::new();
1402 extra.insert(
1403 "pattern".to_string(),
1404 serde_json::Value::String("error".to_string()),
1405 );
1406 let mut mem = test_memory_with_metadata(&format!("ns-a grep {i}"), "Grep", extra);
1407 mem.namespace = Some("project-a".to_string());
1408 storage.insert_memory(&mem).unwrap();
1409 }
1410
1411 for i in 0..2 {
1413 let mut extra = HashMap::new();
1414 extra.insert(
1415 "pattern".to_string(),
1416 serde_json::Value::String("error".to_string()),
1417 );
1418 let mut mem = test_memory_with_metadata(&format!("ns-b grep {i}"), "Grep", extra);
1419 mem.namespace = Some("project-b".to_string());
1420 storage.insert_memory(&mem).unwrap();
1421 }
1422
1423 let results = storage.get_repeated_searches(1, None).unwrap();
1425 assert_eq!(results.len(), 1);
1426 assert_eq!(results[0].1, 5);
1427
1428 let results = storage.get_repeated_searches(1, Some("project-a")).unwrap();
1430 assert_eq!(results.len(), 1);
1431 assert_eq!(results[0].1, 3);
1432 }
1433
1434 #[test]
1437 fn session_lifecycle() {
1438 let storage = Storage::open_in_memory().unwrap();
1439
1440 storage.start_session("sess-1", Some("my-project")).unwrap();
1442
1443 let sessions = storage.list_sessions(Some("my-project")).unwrap();
1445 assert_eq!(sessions.len(), 1);
1446 assert_eq!(sessions[0].id, "sess-1");
1447 assert_eq!(sessions[0].namespace, Some("my-project".to_string()));
1448 assert!(sessions[0].ended_at.is_none());
1449
1450 storage
1452 .end_session("sess-1", Some("Explored the codebase"))
1453 .unwrap();
1454
1455 let sessions = storage.list_sessions(None).unwrap();
1456 assert_eq!(sessions.len(), 1);
1457 assert!(sessions[0].ended_at.is_some());
1458 assert_eq!(
1459 sessions[0].summary,
1460 Some("Explored the codebase".to_string())
1461 );
1462 }
1463
1464 #[test]
1465 fn ensure_session_column_idempotent() {
1466 let storage = Storage::open_in_memory().unwrap();
1467 storage.ensure_session_column().unwrap();
1469 storage.ensure_session_column().unwrap();
1470 }
1471
1472 #[test]
1473 fn list_sessions_filters_by_namespace() {
1474 let storage = Storage::open_in_memory().unwrap();
1475
1476 storage.start_session("sess-a", Some("project-a")).unwrap();
1477 storage.start_session("sess-b", Some("project-b")).unwrap();
1478 storage.start_session("sess-c", None).unwrap();
1479
1480 let all = storage.list_sessions(None).unwrap();
1481 assert_eq!(all.len(), 3);
1482
1483 let proj_a = storage.list_sessions(Some("project-a")).unwrap();
1484 assert_eq!(proj_a.len(), 1);
1485 assert_eq!(proj_a[0].id, "sess-a");
1486 }
1487
1488 #[test]
1489 fn start_session_ignores_duplicate() {
1490 let storage = Storage::open_in_memory().unwrap();
1491 storage.start_session("sess-1", Some("ns")).unwrap();
1492 storage.start_session("sess-1", Some("ns")).unwrap();
1494
1495 let sessions = storage.list_sessions(None).unwrap();
1496 assert_eq!(sessions.len(), 1);
1497 }
1498}