1use crate::error::{BrainError, Result};
7use crate::fts::escape_fts5_query;
8use crate::types::{Edge, Node, NodeType};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13#[cfg(feature = "ast")]
14use crate::ast::SymbolAnchor;
15
16pub struct Database {
21 pub(crate) conn: Connection,
22}
23
24impl Database {
25 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
27 let conn = Connection::open(path.as_ref())?;
28 let db = Self { conn };
29 db.configure_connection()?;
30 super::migrations::migrate(&db.conn)?;
31 Ok(db)
32 }
33
34 pub fn open_in_memory() -> Result<Self> {
36 let conn = Connection::open_in_memory()?;
37 let db = Self { conn };
38 db.configure_connection()?;
39 super::migrations::migrate(&db.conn)?;
40 Ok(db)
41 }
42
43 fn configure_connection(&self) -> Result<()> {
44 self.conn.pragma_update(None, "foreign_keys", true)?;
45 self.conn.pragma_update(None, "busy_timeout", 5000i32)?;
46 let _ = self.conn.pragma_update(None, "journal_mode", "WAL");
48 let _ = self.conn.pragma_update(None, "synchronous", "NORMAL");
49 Ok(())
50 }
51
52 pub fn with_transaction<T, F>(&self, f: F) -> Result<T>
54 where
55 F: FnOnce(&Connection) -> Result<T>,
56 {
57 let tx = self.conn.unchecked_transaction()?;
58 let result = f(&tx)?;
59 tx.commit()?;
60 Ok(result)
61 }
62
63 pub fn insert_node(&self, node: &Node) -> Result<()> {
66 self.insert_node_on(&self.conn, node)
67 }
68
69 pub(crate) fn insert_node_on(&self, conn: &Connection, node: &Node) -> Result<()> {
70 let existing_created: Option<i64> = conn
72 .query_row(
73 "SELECT created_at FROM nodes WHERE id = ?1",
74 [&node.id],
75 |row| row.get(0),
76 )
77 .optional()?;
78
79 let created_at = existing_created.unwrap_or(node.created_at);
80
81 conn.execute(
82 "INSERT INTO nodes (id, node_type, title, file_path, symbol_hash, summary, content_hash, created_at, updated_at)
83 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
84 ON CONFLICT(id) DO UPDATE SET
85 node_type = excluded.node_type,
86 title = excluded.title,
87 file_path = excluded.file_path,
88 symbol_hash = excluded.symbol_hash,
89 summary = excluded.summary,
90 content_hash = excluded.content_hash,
91 updated_at = excluded.updated_at",
92 params![
93 node.id,
94 node.node_type.as_str(),
95 node.title,
96 node.file_path,
97 node.symbol_hash.map(|h| h as i64),
98 node.summary,
99 node.content_hash,
100 created_at,
101 node.updated_at,
102 ],
103 )?;
104 Ok(())
105 }
106
107 pub fn get_content_hash(&self, node_id: &str) -> Result<Option<String>> {
109 let v: Option<String> = self
110 .conn
111 .query_row(
112 "SELECT content_hash FROM nodes WHERE id = ?1",
113 [node_id],
114 |row| row.get(0),
115 )
116 .optional()?;
117 Ok(v)
118 }
119
120 pub fn insert_edge(&self, edge: &Edge) -> Result<()> {
122 self.insert_edge_on(&self.conn, edge)
123 }
124
125 pub(crate) fn insert_edge_on(&self, conn: &Connection, edge: &Edge) -> Result<()> {
126 conn.execute(
127 "INSERT INTO edges (source_id, target_id, relation_type, weight, decay_rate, created_at)
128 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
129 ON CONFLICT(source_id, target_id, relation_type) DO UPDATE SET
130 weight = excluded.weight,
131 decay_rate = excluded.decay_rate",
132 params![
133 edge.source_id,
134 edge.target_id,
135 edge.relation_type,
136 edge.weight,
137 edge.decay_rate,
138 edge.created_at,
139 ],
140 )?;
141 Ok(())
142 }
143
144 pub fn insert_pending_link(
146 &self,
147 source_id: &str,
148 raw_target: &str,
149 relation_type: &str,
150 created_at: i64,
151 ) -> Result<()> {
152 self.insert_pending_link_on(&self.conn, source_id, raw_target, relation_type, created_at)
153 }
154
155 pub(crate) fn insert_pending_link_on(
156 &self,
157 conn: &Connection,
158 source_id: &str,
159 raw_target: &str,
160 relation_type: &str,
161 created_at: i64,
162 ) -> Result<()> {
163 conn.execute(
164 "INSERT INTO pending_links (source_id, raw_target, relation_type, created_at)
165 VALUES (?1, ?2, ?3, ?4)
166 ON CONFLICT(source_id, raw_target, relation_type) DO NOTHING",
167 params![source_id, raw_target, relation_type, created_at],
168 )?;
169 Ok(())
170 }
171
172 pub fn clear_pending_links_for(&self, source_id: &str) -> Result<()> {
174 self.conn
175 .execute("DELETE FROM pending_links WHERE source_id = ?1", [source_id])?;
176 Ok(())
177 }
178
179 pub(crate) fn clear_pending_links_for_on(
180 &self,
181 conn: &Connection,
182 source_id: &str,
183 ) -> Result<()> {
184 conn.execute("DELETE FROM pending_links WHERE source_id = ?1", [source_id])?;
185 Ok(())
186 }
187
188 pub(crate) fn clear_edges_from_on(
190 &self,
191 conn: &Connection,
192 source_id: &str,
193 relation_type: &str,
194 ) -> Result<()> {
195 conn.execute(
196 "DELETE FROM edges WHERE source_id = ?1 AND relation_type = ?2",
197 params![source_id, relation_type],
198 )?;
199 Ok(())
200 }
201
202 pub fn clear_all_auto_edges(&self) -> Result<()> {
204 self.conn
205 .execute("DELETE FROM edges WHERE relation_type LIKE 'auto_%'", [])?;
206 Ok(())
207 }
208
209 pub fn clear_auto_edges_involving(&self, node_id: &str) -> Result<()> {
211 self.conn.execute(
212 "DELETE FROM edges WHERE relation_type LIKE 'auto_%' AND (source_id = ?1 OR target_id = ?1)",
213 [node_id],
214 )?;
215 Ok(())
216 }
217
218 #[cfg(feature = "ast")]
220 pub fn insert_symbol_anchor(&self, anchor: &SymbolAnchor) -> Result<()> {
221 self.conn.execute(
222 "INSERT INTO symbol_anchors (symbol_hash, crate_name, module_path, symbol_name, file_path, start_line, end_line, doc_comment)
223 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
224 ON CONFLICT(symbol_hash) DO UPDATE SET
225 crate_name = excluded.crate_name,
226 module_path = excluded.module_path,
227 symbol_name = excluded.symbol_name,
228 file_path = excluded.file_path,
229 start_line = excluded.start_line,
230 end_line = excluded.end_line,
231 doc_comment = excluded.doc_comment",
232 params![
233 anchor.symbol_hash as i64,
234 anchor.crate_name,
235 anchor.module_path,
236 anchor.symbol_name,
237 anchor.file_path,
238 anchor.start_line,
239 anchor.end_line,
240 anchor.doc_comment,
241 ],
242 )?;
243 Ok(())
244 }
245
246 pub fn replace_node_tags(&self, node_id: &str, tags: &[String]) -> Result<()> {
248 self.replace_node_tags_on(&self.conn, node_id, tags)
249 }
250
251 pub(crate) fn replace_node_tags_on(
252 &self,
253 conn: &Connection,
254 node_id: &str,
255 tags: &[String],
256 ) -> Result<()> {
257 conn.execute("DELETE FROM node_tags WHERE node_id = ?1", [node_id])?;
258 for tag in tags {
259 conn.execute(
260 "INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?1, ?2)",
261 params![node_id, tag],
262 )?;
263 }
264 Ok(())
265 }
266
267 pub fn replace_node_aliases(&self, node_id: &str, aliases: &[String]) -> Result<()> {
269 self.replace_node_aliases_on(&self.conn, node_id, aliases)
270 }
271
272 pub(crate) fn replace_node_aliases_on(
273 &self,
274 conn: &Connection,
275 node_id: &str,
276 aliases: &[String],
277 ) -> Result<()> {
278 conn.execute("DELETE FROM node_aliases WHERE node_id = ?1", [node_id])?;
279 for alias in aliases {
280 let key = alias.trim().to_lowercase();
281 if key.is_empty() {
282 continue;
283 }
284 conn.execute(
285 "INSERT INTO node_aliases (alias, node_id) VALUES (?1, ?2)
286 ON CONFLICT(alias) DO UPDATE SET node_id = excluded.node_id",
287 params![key, node_id],
288 )?;
289 }
290 Ok(())
291 }
292
293 pub fn index_fts(&self, node_id: &str, title: &str, content: &str, tags: &str) -> Result<()> {
295 self.index_fts_on(&self.conn, node_id, title, content, tags)
296 }
297
298 pub fn get_fts_content(&self, node_id: &str) -> Result<Option<String>> {
300 let mut stmt = self
301 .conn
302 .prepare("SELECT content FROM node_fts WHERE node_id = ?1 LIMIT 1")?;
303 let content: Option<String> = stmt
304 .query_row(params![node_id], |row| row.get(0))
305 .optional()?;
306 Ok(content)
307 }
308
309 pub(crate) fn index_fts_on(
310 &self,
311 conn: &Connection,
312 node_id: &str,
313 title: &str,
314 content: &str,
315 tags: &str,
316 ) -> Result<()> {
317 conn.execute("DELETE FROM node_fts WHERE node_id = ?1", [node_id])?;
318 conn.execute(
319 "INSERT INTO node_fts (node_id, title, content, tags) VALUES (?1, ?2, ?3, ?4)",
320 params![node_id, title, content, tags],
321 )?;
322 Ok(())
323 }
324
325 pub fn search_fts(&self, query: &str) -> Result<Vec<Node>> {
327 let escaped = escape_fts5_query(query)?;
328 let mut stmt = self.conn.prepare(
329 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
330 n.content_hash, n.created_at, n.updated_at
331 FROM node_fts f
332 JOIN nodes n ON n.id = f.node_id
333 WHERE node_fts MATCH ?1
334 ORDER BY rank
335 LIMIT 50",
336 )?;
337
338 let rows = stmt.query_map(params![escaped], |row| {
339 let type_str: String = row.get(1)?;
340 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
341 let symbol_hash_raw: Option<i64> = row.get(4)?;
342 Ok(Node {
343 id: row.get(0)?,
344 node_type,
345 title: row.get(2)?,
346 file_path: row.get(3)?,
347 symbol_hash: symbol_hash_raw.map(|h| h as u64),
348 summary: row.get(5)?,
349 content_hash: row.get(6)?,
350 created_at: row.get(7)?,
351 updated_at: row.get(8)?,
352 })
353 })?;
354
355 let mut results = Vec::new();
356 for r in rows {
357 results.push(r.map_err(BrainError::from)?);
358 }
359 Ok(results)
360 }
361
362 pub fn list_node_ids_by_type(&self, node_type: &str) -> Result<Vec<String>> {
364 let mut stmt = self
365 .conn
366 .prepare("SELECT id FROM nodes WHERE node_type = ?1 ORDER BY id")?;
367 let rows = stmt.query_map(params![node_type], |row| row.get(0))?;
368 let mut out = Vec::new();
369 for r in rows {
370 out.push(r?);
371 }
372 Ok(out)
373 }
374
375 pub fn get_node(&self, id: &str) -> Result<Option<Node>> {
377 let mut stmt = self.conn.prepare(
378 "SELECT id, node_type, title, file_path, symbol_hash, summary, content_hash, created_at, updated_at
379 FROM nodes WHERE id = ?1",
380 )?;
381 let node = stmt
382 .query_row(params![id], Self::map_node_row)
383 .optional()?;
384 Ok(node)
385 }
386
387 fn map_node_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Node> {
388 let type_str: String = row.get(1)?;
389 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
390 let symbol_hash_raw: Option<i64> = row.get(4)?;
391 Ok(Node {
392 id: row.get(0)?,
393 node_type,
394 title: row.get(2)?,
395 file_path: row.get(3)?,
396 symbol_hash: symbol_hash_raw.map(|h| h as u64),
397 summary: row.get(5)?,
398 content_hash: row.get(6)?,
399 created_at: row.get(7)?,
400 updated_at: row.get(8)?,
401 })
402 }
403
404 pub fn count_nodes(&self) -> Result<usize> {
406 let count: usize = self
407 .conn
408 .query_row("SELECT COUNT(*) FROM nodes", [], |row| row.get(0))?;
409 Ok(count)
410 }
411
412 pub fn count_edges(&self) -> Result<usize> {
414 let count: usize = self
415 .conn
416 .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
417 Ok(count)
418 }
419
420 pub fn count_fts_rows(&self) -> Result<usize> {
422 let count: usize = self
423 .conn
424 .query_row("SELECT COUNT(*) FROM node_fts", [], |row| row.get(0))?;
425 Ok(count)
426 }
427
428 pub fn count_pending_links(&self) -> Result<usize> {
430 let count: usize = self
431 .conn
432 .query_row("SELECT COUNT(*) FROM pending_links", [], |row| row.get(0))?;
433 Ok(count)
434 }
435
436 pub fn count_symbol_anchors(&self) -> Result<usize> {
438 let count: usize = self
439 .conn
440 .query_row("SELECT COUNT(*) FROM symbol_anchors", [], |row| row.get(0))?;
441 Ok(count)
442 }
443
444 pub fn get_all_node_ids(&self) -> Result<Vec<String>> {
446 let mut stmt = self
447 .conn
448 .prepare("SELECT id FROM nodes ORDER BY id ASC")?;
449 let rows = stmt.query_map([], |row| row.get(0))?;
450 let mut ids = Vec::new();
451 for r in rows {
452 ids.push(r?);
453 }
454 Ok(ids)
455 }
456
457 pub fn get_all_edges(&self) -> Result<Vec<Edge>> {
459 let mut stmt = self.conn.prepare(
460 "SELECT source_id, target_id, relation_type, weight, decay_rate, created_at FROM edges",
461 )?;
462 let rows = stmt.query_map([], |row| {
463 let weight: f64 = row.get(3)?;
464 let decay: f64 = row.get(4)?;
465 Ok(Edge {
466 source_id: row.get(0)?,
467 target_id: row.get(1)?,
468 relation_type: row.get(2)?,
469 weight: weight as f32,
470 decay_rate: decay as f32,
471 created_at: row.get(5)?,
472 })
473 })?;
474
475 let mut edges = Vec::new();
476 for r in rows {
477 edges.push(r?);
478 }
479 Ok(edges)
480 }
481
482 pub fn get_csr_edges(&self) -> Result<Vec<(String, String, f32)>> {
484 Ok(self
485 .get_all_edges()?
486 .into_iter()
487 .map(|e| (e.source_id, e.target_id, e.weight))
488 .collect())
489 }
490
491 pub fn get_all_nodes(&self) -> Result<Vec<Node>> {
493 let mut stmt = self.conn.prepare(
494 "SELECT id, node_type, title, file_path, symbol_hash, summary, content_hash, created_at, updated_at
495 FROM nodes ORDER BY id ASC",
496 )?;
497 let rows = stmt.query_map([], Self::map_node_row)?;
498 let mut nodes = Vec::new();
499 for r in rows {
500 nodes.push(r?);
501 }
502 Ok(nodes)
503 }
504
505 #[allow(clippy::type_complexity)]
507 pub fn link_resolution_maps(
508 &self,
509 ) -> Result<(
510 HashSet<String>,
511 HashMap<String, String>,
512 HashMap<String, String>,
513 )> {
514 let mut ids = HashSet::new();
515 let mut titles: HashMap<String, String> = HashMap::new();
516 {
517 let mut stmt = self.conn.prepare("SELECT id, title FROM nodes")?;
518 let rows = stmt.query_map([], |row| {
519 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
520 })?;
521 for r in rows {
522 let (id, title) = r?;
523 titles
524 .entry(title.to_lowercase())
525 .or_insert_with(|| id.clone());
526 ids.insert(id);
527 }
528 }
529
530 let mut aliases: HashMap<String, String> = HashMap::new();
531 {
532 let mut stmt = self.conn.prepare("SELECT alias, node_id FROM node_aliases")?;
533 let rows = stmt.query_map([], |row| {
534 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
535 })?;
536 for r in rows {
537 let (alias, node_id) = r?;
538 aliases.insert(alias, node_id);
539 }
540 }
541
542 Ok((ids, aliases, titles))
543 }
544
545 pub fn resolve_pending_links(&self) -> Result<(usize, usize)> {
547 let (ids, aliases, titles) = self.link_resolution_maps()?;
548 let pending: Vec<(String, String, String, i64)> = {
549 let mut stmt = self.conn.prepare(
550 "SELECT source_id, raw_target, relation_type, created_at FROM pending_links",
551 )?;
552 let rows = stmt.query_map([], |row| {
553 Ok((
554 row.get::<_, String>(0)?,
555 row.get::<_, String>(1)?,
556 row.get::<_, String>(2)?,
557 row.get::<_, i64>(3)?,
558 ))
559 })?;
560 let mut v = Vec::new();
561 for r in rows {
562 v.push(r?);
563 }
564 v
565 };
566
567 let symbol_ids: std::collections::HashSet<String> = ids
569 .iter()
570 .filter(|id| id.starts_with("symbol/"))
571 .cloned()
572 .collect();
573
574 let mut resolved = 0usize;
575 for (source_id, raw_target, relation_type, created_at) in &pending {
576 let target_id = if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
577 crate::symbols::parse_symbol_path(sym_path)
578 .and_then(|s| crate::symbols::resolve_symbol_ref(&s, &symbol_ids))
579 .or_else(|| {
580 crate::symbols::parse_symbol_path(sym_path).and_then(|s| {
582 crate::id::resolve_link_target(
583 &s.symbol_name,
584 &ids,
585 &aliases,
586 &titles,
587 )
588 })
589 })
590 } else {
591 crate::id::resolve_link_target(raw_target, &ids, &aliases, &titles)
592 };
593
594 if let Some(target_id) = target_id {
595 let edge = Edge {
596 source_id: source_id.clone(),
597 target_id,
598 relation_type: relation_type.clone(),
599 weight: 1.0,
600 decay_rate: 0.0,
601 created_at: *created_at,
602 };
603 self.insert_edge(&edge)?;
604 self.conn.execute(
605 "DELETE FROM pending_links WHERE source_id = ?1 AND raw_target = ?2 AND relation_type = ?3",
606 params![source_id, raw_target, relation_type],
607 )?;
608 resolved += 1;
609 }
610 }
611
612 let still = self.count_pending_links()?;
613 Ok((resolved, still))
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use crate::types::NodeType;
621
622 fn sample_node(id: &str) -> Node {
623 Node {
624 id: id.to_string(),
625 node_type: NodeType::Concept,
626 title: id.to_string(),
627 file_path: Some(format!("{id}.md")),
628 symbol_hash: None,
629 summary: Some("summary".into()),
630 content_hash: Some("abc".into()),
631 created_at: 100,
632 updated_at: 100,
633 }
634 }
635
636 #[test]
637 fn fts_idempotent_reindex() {
638 let db = Database::open_in_memory().unwrap();
639 let n = sample_node("docs/raft");
640 db.insert_node(&n).unwrap();
641 db.index_fts(&n.id, &n.title, "raft consensus protocol", "raft").unwrap();
642 db.index_fts(&n.id, &n.title, "raft consensus protocol v2", "raft").unwrap();
643 assert_eq!(db.count_fts_rows().unwrap(), 1);
644 let hits = db.search_fts("raft").unwrap();
645 assert_eq!(hits.len(), 1);
646 }
647
648 #[test]
649 fn edge_requires_endpoints() {
650 let db = Database::open_in_memory().unwrap();
651 let edge = Edge {
652 source_id: "a".into(),
653 target_id: "b".into(),
654 relation_type: "relates_to".into(),
655 weight: 1.0,
656 decay_rate: 0.0,
657 created_at: 1,
658 };
659 assert!(db.insert_edge(&edge).is_err());
660 }
661
662 #[test]
663 fn full_edge_roundtrip() {
664 let db = Database::open_in_memory().unwrap();
665 db.insert_node(&sample_node("a")).unwrap();
666 db.insert_node(&sample_node("b")).unwrap();
667 let edge = Edge {
668 source_id: "a".into(),
669 target_id: "b".into(),
670 relation_type: "implements".into(),
671 weight: 0.75,
672 decay_rate: 0.1,
673 created_at: 42,
674 };
675 db.insert_edge(&edge).unwrap();
676 let edges = db.get_all_edges().unwrap();
677 assert_eq!(edges.len(), 1);
678 assert_eq!(edges[0].relation_type, "implements");
679 assert!((edges[0].weight - 0.75).abs() < 1e-6);
680 assert!((edges[0].decay_rate - 0.1).abs() < 1e-6);
681 assert_eq!(edges[0].created_at, 42);
682 }
683
684 #[test]
685 fn preserves_created_at_on_upsert() {
686 let db = Database::open_in_memory().unwrap();
687 let mut n = sample_node("x");
688 n.created_at = 10;
689 n.updated_at = 10;
690 db.insert_node(&n).unwrap();
691 n.created_at = 999;
692 n.updated_at = 20;
693 n.title = "updated".into();
694 db.insert_node(&n).unwrap();
695 let got = db.get_node("x").unwrap().unwrap();
696 assert_eq!(got.created_at, 10);
697 assert_eq!(got.updated_at, 20);
698 assert_eq!(got.title, "updated");
699 }
700}