1use std::collections::HashSet;
45use std::sync::{Mutex, MutexGuard};
46use std::time::{SystemTime, UNIX_EPOCH};
47
48use postgres::row::Row;
49use postgres::types::ToSql;
50use postgres::{Client, Config, NoTls};
51
52use super::algo::{CsrGraph, pagerank_default};
53use super::lifecycle::now_iso;
54use super::recall::{W_ACCESS, W_FTS, W_GRAPH, W_IMPORTANCE, W_RECENCY, compute_recency};
55use super::schema::GRAPH_SCHEMA_VERSION;
56use super::types::{EdgeDirection, escape_like, join_csv, split_csv};
57use super::{GraphBackend, GraphEdge, GraphNode, ScoredNode};
58use crate::error::{KernelError, Result};
59
60const NODE_COLUMNS: &str = "id, node_type, title, tags, projects, agents, \
62 created, updated, body, importance, access_count, accessed_at";
63
64fn pg_err(e: postgres::Error) -> KernelError {
66 KernelError::Store(format!("postgres: {e:?}"))
67}
68
69fn row_to_node(row: &Row) -> GraphNode {
71 GraphNode {
72 id: row.get(0),
73 node_type: row.get(1),
74 title: row.get(2),
75 tags: split_csv(&row.get::<_, String>(3)),
76 projects: split_csv(&row.get::<_, String>(4)),
77 agents: split_csv(&row.get::<_, String>(5)),
78 created: row.get(6),
79 updated: row.get(7),
80 body: row.get(8),
81 importance: row.get(9),
82 access_count: row.get(10),
83 accessed_at: row.get(11),
84 }
85}
86
87fn row_to_edge(row: &Row) -> GraphEdge {
91 GraphEdge {
92 id: row.get(0),
93 source: row.get(1),
94 target: row.get(2),
95 relation: row.get(3),
96 weight: row.get(4),
97 ts: row.get(5),
98 }
99}
100
101fn search_patterns(query: &str) -> Vec<String> {
105 query
106 .split_whitespace()
107 .map(|t| format!("%{}%", escape_like(t)))
108 .collect()
109}
110
111fn is_identifier_safe(prefix: &str) -> bool {
120 let mut chars = prefix.chars();
121 match chars.next() {
122 None => true,
123 Some(first) if first == '_' || first.is_ascii_alphabetic() => {
124 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
125 }
126 Some(_) => false,
127 }
128}
129
130fn init_schema(client: &mut Client, prefix: &str) -> Result<()> {
133 let nodes = format!("{prefix}nodes");
134 let edges = format!("{prefix}edges");
135 let meta = format!("{prefix}_meta");
136 let idx_edges_source = format!("{prefix}idx_edges_source");
137 let idx_edges_target = format!("{prefix}idx_edges_target");
138 let idx_edges_src_tgt_rel = format!("{prefix}idx_edges_src_tgt_rel");
139 let idx_edges_src_rel = format!("{prefix}idx_edges_src_rel");
140 let idx_edges_tgt_rel = format!("{prefix}idx_edges_tgt_rel");
141 let idx_nodes_type = format!("{prefix}idx_nodes_type");
142 let idx_nodes_updated = format!("{prefix}idx_nodes_updated");
143 let idx_nodes_importance = format!("{prefix}idx_nodes_importance");
144 let idx_nodes_accessed = format!("{prefix}idx_nodes_accessed");
145 let idx_nodes_created = format!("{prefix}idx_nodes_created");
146 client
147 .batch_execute(&format!(
148 "CREATE TABLE IF NOT EXISTS {nodes} (
149 id TEXT PRIMARY KEY,
150 node_type TEXT NOT NULL,
151 title TEXT NOT NULL,
152 tags TEXT NOT NULL DEFAULT '',
153 projects TEXT NOT NULL DEFAULT '',
154 agents TEXT NOT NULL DEFAULT '',
155 created TEXT NOT NULL,
156 updated TEXT NOT NULL,
157 body TEXT NOT NULL DEFAULT '',
158 importance DOUBLE PRECISION NOT NULL DEFAULT 0.5,
159 access_count BIGINT NOT NULL DEFAULT 0,
160 accessed_at TEXT NOT NULL DEFAULT ''
161 );
162 CREATE TABLE IF NOT EXISTS {edges} (
163 id TEXT PRIMARY KEY,
164 source TEXT NOT NULL,
165 target TEXT NOT NULL,
166 relation TEXT NOT NULL DEFAULT 'related',
167 weight DOUBLE PRECISION NOT NULL DEFAULT 1.0,
168 ts TEXT NOT NULL
169 );
170 CREATE INDEX IF NOT EXISTS {idx_edges_source} ON {edges}(source);
171 CREATE INDEX IF NOT EXISTS {idx_edges_target} ON {edges}(target);
172 CREATE UNIQUE INDEX IF NOT EXISTS {idx_edges_src_tgt_rel} ON {edges}(source, target, relation);
173 CREATE INDEX IF NOT EXISTS {idx_edges_src_rel} ON {edges}(source, relation);
174 CREATE INDEX IF NOT EXISTS {idx_edges_tgt_rel} ON {edges}(target, relation);
175 CREATE INDEX IF NOT EXISTS {idx_nodes_type} ON {nodes}(node_type);
176 CREATE INDEX IF NOT EXISTS {idx_nodes_updated} ON {nodes}(updated DESC);
177 CREATE INDEX IF NOT EXISTS {idx_nodes_importance} ON {nodes}(importance DESC);
178 CREATE INDEX IF NOT EXISTS {idx_nodes_accessed} ON {nodes}(accessed_at DESC);
179 CREATE INDEX IF NOT EXISTS {idx_nodes_created} ON {nodes}(created);
180 CREATE TABLE IF NOT EXISTS {meta} (key TEXT PRIMARY KEY, value TEXT NOT NULL);
181 INSERT INTO {meta} (key, value) VALUES ('graph_schema_version', '3')
182 ON CONFLICT (key) DO NOTHING;",
183 ))
184 .map_err(pg_err)?;
185 Ok(())
186}
187
188fn schema_version(client: &mut Client, prefix: &str) -> Result<u32> {
190 let meta = format!("{prefix}_meta");
191 let row = client
192 .query_opt(
193 &format!("SELECT value FROM {meta} WHERE key = 'graph_schema_version'"),
194 &[],
195 )
196 .map_err(pg_err)?;
197 Ok(row
198 .map(|r| r.get::<_, String>(0))
199 .and_then(|s| s.parse().ok())
200 .unwrap_or(0))
201}
202
203fn migrate(client: &mut Client, current: u32, prefix: &str) -> Result<u32> {
208 if current >= GRAPH_SCHEMA_VERSION {
209 return Ok(current);
210 }
211 let nodes = format!("{prefix}nodes");
212 let edges = format!("{prefix}edges");
213 let meta = format!("{prefix}_meta");
214 let idx_nodes_created = format!("{prefix}idx_nodes_created");
215 let idx_edges_src_rel = format!("{prefix}idx_edges_src_rel");
216 let idx_edges_tgt_rel = format!("{prefix}idx_edges_tgt_rel");
217 let mut tx = client.transaction().map_err(pg_err)?;
218 let mut v = current;
219 if v < 2 {
220 tx.batch_execute(&format!(
221 "CREATE INDEX IF NOT EXISTS {idx_nodes_created} ON {nodes}(created);"
222 ))
223 .map_err(pg_err)?;
224 v = 2;
225 }
226 if v < 3 {
228 tx.batch_execute(&format!(
229 "CREATE INDEX IF NOT EXISTS {idx_edges_src_rel} ON {edges}(source, relation);
230 CREATE INDEX IF NOT EXISTS {idx_edges_tgt_rel} ON {edges}(target, relation);",
231 ))
232 .map_err(pg_err)?;
233 v = 3;
234 }
235 tx.execute(
236 &format!("UPDATE {meta} SET value = $1 WHERE key = 'graph_schema_version'"),
237 &[&v.to_string()],
238 )
239 .map_err(pg_err)?;
240 tx.commit().map_err(pg_err)?;
241 Ok(v)
242}
243
244pub struct PgGraph {
251 client: Mutex<Client>,
252 table_prefix: String,
253}
254
255impl PgGraph {
256 pub fn connect(url: &str) -> Result<Self> {
260 Self::connect_config(&Self::parse_config(url)?)
261 }
262
263 pub fn connect_with_prefix(url: &str, prefix: &str) -> Result<Self> {
267 Self::connect_config_with_prefix(&Self::parse_config(url)?, prefix)
268 }
269
270 pub fn connect_config(config: &Config) -> Result<Self> {
274 let client = config.connect(NoTls).map_err(pg_err)?;
275 Self::from_client(client)
276 }
277
278 pub fn connect_config_with_prefix(config: &Config, prefix: &str) -> Result<Self> {
281 let client = config.connect(NoTls).map_err(pg_err)?;
282 Self::from_client_with_prefix(client, prefix)
283 }
284
285 #[cfg(feature = "graph-pg-tls")]
291 pub fn connect_tls<T>(url: &str, connector: T) -> Result<Self>
292 where
293 T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
294 T::TlsConnect: Send,
295 T::Stream: Send,
296 <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
297 {
298 Self::connect_config_tls(&Self::parse_config(url)?, connector)
299 }
300
301 #[cfg(feature = "graph-pg-tls")]
304 pub fn connect_tls_with_prefix<T>(url: &str, prefix: &str, connector: T) -> Result<Self>
305 where
306 T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
307 T::TlsConnect: Send,
308 T::Stream: Send,
309 <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
310 {
311 Self::connect_config_tls_with_prefix(&Self::parse_config(url)?, prefix, connector)
312 }
313
314 #[cfg(feature = "graph-pg-tls")]
318 pub fn connect_config_tls<T>(config: &Config, connector: T) -> Result<Self>
319 where
320 T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
321 T::TlsConnect: Send,
322 T::Stream: Send,
323 <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
324 {
325 let client = config.connect(connector).map_err(pg_err)?;
326 Self::from_client(client)
327 }
328
329 #[cfg(feature = "graph-pg-tls")]
332 pub fn connect_config_tls_with_prefix<T>(
333 config: &Config,
334 prefix: &str,
335 connector: T,
336 ) -> Result<Self>
337 where
338 T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
339 T::TlsConnect: Send,
340 T::Stream: Send,
341 <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
342 {
343 let client = config.connect(connector).map_err(pg_err)?;
344 Self::from_client_with_prefix(client, prefix)
345 }
346
347 #[cfg(feature = "graph-pg-tls")]
355 pub fn connect_native_tls(url: &str) -> Result<Self> {
356 let tls = native_tls::TlsConnector::new()
357 .map_err(|e| KernelError::Store(format!("native-tls connector: {e}")))?;
358 Self::connect_tls(url, postgres_native_tls::MakeTlsConnector::new(tls))
359 }
360
361 #[cfg(feature = "graph-pg-tls")]
364 pub fn connect_native_tls_with_prefix(url: &str, prefix: &str) -> Result<Self> {
365 let tls = native_tls::TlsConnector::new()
366 .map_err(|e| KernelError::Store(format!("native-tls connector: {e}")))?;
367 Self::connect_tls_with_prefix(url, prefix, postgres_native_tls::MakeTlsConnector::new(tls))
368 }
369
370 fn parse_config(url: &str) -> Result<Config> {
373 url.parse()
374 .map_err(|e| KernelError::Store(format!("invalid postgres config: {e}")))
375 }
376
377 pub fn from_client(client: Client) -> Result<Self> {
384 Self::from_client_with_prefix(client, "")
385 }
386
387 pub fn from_client_with_prefix(mut client: Client, prefix: &str) -> Result<Self> {
392 if !is_identifier_safe(prefix) {
393 return Err(KernelError::Store(format!(
394 "invalid table prefix {prefix:?}: only ASCII letters, digits, and underscore are allowed (and the first character must not be a digit)"
395 )));
396 }
397 init_schema(&mut client, prefix)?;
398 let current = schema_version(&mut client, prefix)?;
399 migrate(&mut client, current, prefix)?;
400 Ok(Self {
401 client: Mutex::new(client),
402 table_prefix: prefix.to_string(),
403 })
404 }
405
406 fn lock(&self) -> MutexGuard<'_, Client> {
407 self.client.lock().unwrap_or_else(|e| e.into_inner())
408 }
409
410 fn nodes_tbl(&self) -> String {
412 format!("{}nodes", self.table_prefix)
413 }
414
415 fn edges_tbl(&self) -> String {
417 format!("{}edges", self.table_prefix)
418 }
419
420 fn meta_tbl(&self) -> String {
422 format!("{}_meta", self.table_prefix)
423 }
424
425 pub fn list_nodes(&self, limit: usize) -> Result<Vec<GraphNode>> {
429 let nodes = self.nodes_tbl();
430 let mut c = self.lock();
431 let sql = format!("SELECT {NODE_COLUMNS} FROM {nodes} ORDER BY updated DESC LIMIT {limit}");
432 let rows = c.query(&sql, &[]).map_err(pg_err)?;
433 Ok(rows.iter().map(row_to_node).collect())
434 }
435
436 pub fn list_edges(&self, limit: usize) -> Result<Vec<GraphEdge>> {
438 let edges = self.edges_tbl();
439 let mut c = self.lock();
440 let rows = c
441 .query(
442 &format!("SELECT id, source, target, relation, weight, ts FROM {edges} LIMIT $1"),
443 &[&(limit as i64)],
444 )
445 .map_err(pg_err)?;
446 Ok(rows.iter().map(row_to_edge).collect())
447 }
448}
449
450impl GraphBackend for PgGraph {
451 fn upsert_node(&self, node: &GraphNode) -> Result<()> {
452 let tags = join_csv(&node.tags);
453 let projects = join_csv(&node.projects);
454 let agents = join_csv(&node.agents);
455 let params: [&(dyn ToSql + Sync); 12] = [
456 &node.id,
457 &node.node_type,
458 &node.title,
459 &tags,
460 &projects,
461 &agents,
462 &node.created,
463 &node.updated,
464 &node.body,
465 &node.importance,
466 &node.access_count,
467 &node.accessed_at,
468 ];
469 let nodes = self.nodes_tbl();
470 let mut c = self.lock();
471 c.execute(
472 &format!(
473 "INSERT INTO {nodes} (id, node_type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at)
474 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
475 ON CONFLICT (id) DO UPDATE SET
476 node_type=EXCLUDED.node_type, title=EXCLUDED.title, tags=EXCLUDED.tags,
477 projects=EXCLUDED.projects, agents=EXCLUDED.agents, created=EXCLUDED.created,
478 updated=EXCLUDED.updated, body=EXCLUDED.body, importance=EXCLUDED.importance,
479 access_count=EXCLUDED.access_count, accessed_at=EXCLUDED.accessed_at"
480 ),
481 ¶ms,
482 )
483 .map_err(pg_err)?;
484 Ok(())
485 }
486
487 fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
488 let nodes = self.nodes_tbl();
489 let mut c = self.lock();
490 let sql = format!("SELECT {NODE_COLUMNS} FROM {nodes} WHERE id = $1");
491 let params: [&(dyn ToSql + Sync); 1] = [&id];
492 let row = c.query_opt(&sql, ¶ms).map_err(pg_err)?;
493 Ok(row.as_ref().map(row_to_node))
494 }
495
496 fn delete_node(&self, id: &str) -> Result<bool> {
497 let nodes = self.nodes_tbl();
498 let mut c = self.lock();
499 let n = c
500 .execute(&format!("DELETE FROM {nodes} WHERE id = $1"), &[&id])
501 .map_err(pg_err)?;
502 Ok(n > 0)
503 }
504
505 fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
506 let terms = search_patterns(query);
507 if terms.is_empty() {
508 return Ok(vec![]);
509 }
510 let mut conds: Vec<String> = Vec::with_capacity(terms.len());
511 let params: Vec<&(dyn ToSql + Sync)> =
512 terms.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
513 for i in 0..terms.len() {
514 conds.push(format!(
515 "(title || ' ' || body || ' ' || tags) ILIKE ${n} ESCAPE '\\'",
516 n = i + 1
517 ));
518 }
519 let where_clause = conds.join(" AND ");
520 let nodes = self.nodes_tbl();
521 let sql = format!(
522 "SELECT {NODE_COLUMNS} FROM {nodes} WHERE {where_clause} ORDER BY importance DESC, updated DESC LIMIT {limit}"
523 );
524 let mut c = self.lock();
525 let rows = c.query(&sql, ¶ms).map_err(pg_err)?;
526 Ok(rows.iter().map(row_to_node).collect())
527 }
528
529 fn query_nodes(
530 &self,
531 tag: Option<&str>,
532 node_type: Option<&str>,
533 project: Option<&str>,
534 limit: usize,
535 ) -> Result<Vec<GraphNode>> {
536 let limit = limit.min(200) as i64;
537 let mut owned: Vec<String> = Vec::new();
538 let mut conds: Vec<String> = Vec::new();
539 if let Some(t) = tag {
540 owned.push(escape_like(t));
541 conds.push(format!(
542 "(',' || tags || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
543 n = owned.len()
544 ));
545 }
546 if let Some(nt) = node_type {
547 owned.push(nt.to_string());
548 conds.push(format!("node_type = ${n}", n = owned.len()));
549 }
550 if let Some(p) = project {
551 owned.push(escape_like(p));
552 conds.push(format!(
553 "(',' || projects || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
554 n = owned.len()
555 ));
556 }
557 let where_clause = if conds.is_empty() {
558 String::new()
559 } else {
560 format!("WHERE {}", conds.join(" AND "))
561 };
562 let params: Vec<&(dyn ToSql + Sync)> =
563 owned.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
564 let nodes = self.nodes_tbl();
565 let sql = format!(
566 "SELECT {NODE_COLUMNS} FROM {nodes} {where_clause} ORDER BY updated DESC LIMIT {limit}"
567 );
568 let mut c = self.lock();
569 let rows = c.query(&sql, ¶ms).map_err(pg_err)?;
570 Ok(rows.iter().map(row_to_node).collect())
571 }
572
573 fn smart_recall(
574 &self,
575 project: Option<&str>,
576 hint: Option<&str>,
577 limit: usize,
578 ) -> Result<Vec<ScoredNode>> {
579 let now_secs = SystemTime::now()
580 .duration_since(UNIX_EPOCH)
581 .unwrap_or_default()
582 .as_secs();
583
584 let fts_ids: HashSet<String> = match hint {
586 Some(h) if !h.is_empty() => self
587 .search_nodes(h, limit * 4)?
588 .into_iter()
589 .map(|n| n.id)
590 .collect(),
591 _ => HashSet::new(),
592 };
593
594 let candidate_limit = (limit * 4).max(40) as i64;
596 let mut owned: Vec<String> = Vec::new();
597 let mut conds: Vec<String> = vec!["(',' || tags || ',') NOT ILIKE '%,stale,%'".to_string()];
598 if let Some(p) = project {
599 owned.push(escape_like(p));
600 conds.push(format!(
601 "(',' || projects || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
602 n = owned.len()
603 ));
604 }
605 let where_clause = conds.join(" AND ");
606 let params: Vec<&(dyn ToSql + Sync)> =
607 owned.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
608 let nodes = self.nodes_tbl();
609 let edges = self.edges_tbl();
610 let sql = format!(
611 "SELECT {NODE_COLUMNS} FROM {nodes} WHERE {where_clause} ORDER BY importance DESC, updated DESC LIMIT {candidate_limit}"
612 );
613 let mut c = self.lock();
614 let rows = c.query(&sql, ¶ms).map_err(pg_err)?;
615 let candidates: Vec<GraphNode> = rows.iter().map(row_to_node).collect();
616
617 let mut scored: Vec<ScoredNode> = candidates
619 .into_iter()
620 .map(|node| {
621 let recency = compute_recency(&node.updated, now_secs);
622 let importance = node.importance;
623 let access_freq = (node.access_count.max(0) as f64 / 20.0).min(1.0);
624 let fts_match = if fts_ids.contains(&node.id) { 1.0 } else { 0.0 };
625 let score = W_RECENCY * recency
626 + W_IMPORTANCE * importance
627 + W_ACCESS * access_freq
628 + W_FTS * fts_match;
629 ScoredNode { node, score }
630 })
631 .collect();
632 scored.sort_by(|a, b| {
633 b.score
634 .partial_cmp(&a.score)
635 .unwrap_or(std::cmp::Ordering::Equal)
636 });
637 scored.truncate(limit);
638
639 if scored.len() > 1 {
643 const MAX_GRAPH_BOOST_PARTICIPANTS: usize = 100;
644 let candidate_ids: Vec<String> = scored
645 .iter()
646 .take(MAX_GRAPH_BOOST_PARTICIPANTS)
647 .map(|sn| sn.node.id.clone())
648 .collect();
649 let n = candidate_ids.len();
650 let l1: String = (1..=n)
651 .map(|i| format!("${i}"))
652 .collect::<Vec<_>>()
653 .join(",");
654 let l2: String = ((n + 1)..=(2 * n))
655 .map(|i| format!("${i}"))
656 .collect::<Vec<_>>()
657 .join(",");
658 let sql = format!(
659 "SELECT id, source, target, relation, weight, ts FROM {edges} WHERE source IN ({l1}) AND target IN ({l2})"
660 );
661 let mut bp: Vec<&(dyn ToSql + Sync)> = Vec::with_capacity(2 * n);
662 for id in &candidate_ids {
663 bp.push(id);
664 }
665 for id in &candidate_ids {
666 bp.push(id);
667 }
668 let sub_edges: Vec<GraphEdge> = match c.query(&sql, &bp) {
669 Ok(rows) => rows
670 .iter()
671 .map(|r| GraphEdge {
672 id: r.get(0),
673 source: r.get(1),
674 target: r.get(2),
675 relation: r.get(3),
676 weight: r.get(4),
677 ts: r.get(5),
678 })
679 .collect(),
680 Err(_) => Vec::new(),
681 };
682 let csr = CsrGraph::from_edges(&candidate_ids, &sub_edges);
683 let pr = pagerank_default(&csr);
684 let max_pr = pr.iter().copied().fold(0.0_f64, f64::max).max(1e-12);
685 let pr_map: std::collections::HashMap<String, f64> = candidate_ids
686 .iter()
687 .zip(pr.iter())
688 .map(|(id, &s)| (id.clone(), s / max_pr))
689 .collect();
690 for sn in &mut scored {
691 let boost = pr_map.get(&sn.node.id).copied().unwrap_or(0.0);
692 sn.score += W_GRAPH * boost;
693 }
694 scored.sort_by(|a, b| {
695 b.score
696 .partial_cmp(&a.score)
697 .unwrap_or(std::cmp::Ordering::Equal)
698 });
699 }
700
701 if !scored.is_empty() {
704 let now = now_iso();
705 let ids: Vec<&str> = scored.iter().map(|sn| sn.node.id.as_str()).collect();
706 let placeholders: String = (0..ids.len())
707 .map(|i| format!("${}", i + 2))
708 .collect::<Vec<_>>()
709 .join(",");
710 let mut params: Vec<&(dyn ToSql + Sync)> = Vec::with_capacity(ids.len() + 1);
711 params.push(&now);
712 for id in &ids {
713 params.push(id);
714 }
715 let sql = format!(
716 "UPDATE {nodes} SET access_count = access_count + 1, accessed_at = $1 WHERE id IN ({placeholders})"
717 );
718 let _ = c.execute(&sql, ¶ms);
719 }
720
721 Ok(scored)
722 }
723
724 fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
725 let edges = self.edges_tbl();
726 let mut c = self.lock();
727 let depth_v = depth as i32;
728 let params: [&(dyn ToSql + Sync); 2] = [&start_id, &depth_v];
729 let rows = c
730 .query(
731 &format!(
735 "WITH RECURSIVE bfs(node_id, lvl) AS (
736 SELECT nb.node_id, 1 FROM (
737 SELECT target AS node_id FROM {edges} WHERE source = $1
738 UNION
739 SELECT source AS node_id FROM {edges} WHERE target = $1
740 ) nb
741 UNION
742 SELECT CASE WHEN e.source = bfs.node_id THEN e.target ELSE e.source END,
743 bfs.lvl + 1
744 FROM bfs
745 JOIN {edges} e ON e.source = bfs.node_id OR e.target = bfs.node_id
746 WHERE bfs.lvl < $2
747 )
748 SELECT DISTINCT node_id FROM bfs WHERE node_id <> $1 LIMIT 500"
749 ),
750 ¶ms,
751 )
752 .map_err(pg_err)?;
753 Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect())
754 }
755
756 fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
757 let params: [&(dyn ToSql + Sync); 6] = [
758 &edge.id,
759 &edge.source,
760 &edge.target,
761 &edge.relation,
762 &edge.weight,
763 &edge.ts,
764 ];
765 let edges = self.edges_tbl();
766 let mut c = self.lock();
767 c.execute(
768 &format!(
769 "INSERT INTO {edges} (id, source, target, relation, weight, ts)
770 VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
771 ),
772 ¶ms,
773 )
774 .map_err(pg_err)?;
775 Ok(())
776 }
777
778 fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
779 if edges.is_empty() {
780 return Ok(());
781 }
782 let edges_tbl = self.edges_tbl();
783 const CHUNK: usize = 5000;
784 let mut c = self.lock();
785 for chunk in edges.chunks(CHUNK) {
786 let mut tx = c.transaction().map_err(pg_err)?;
790 {
791 let stmt = tx
792 .prepare(&format!(
793 "INSERT INTO {edges_tbl} (id, source, target, relation, weight, ts)
794 VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
795 ))
796 .map_err(pg_err)?;
797 for e in chunk {
798 let params: [&(dyn ToSql + Sync); 6] =
799 [&e.id, &e.source, &e.target, &e.relation, &e.weight, &e.ts];
800 tx.execute(&stmt, ¶ms).map_err(pg_err)?;
801 }
802 }
803 tx.commit().map_err(pg_err)?;
804 }
805 Ok(())
806 }
807
808 fn edges_for_node_dir(
809 &self,
810 node_id: &str,
811 dir: EdgeDirection,
812 relation: Option<&str>,
813 ) -> Result<Vec<GraphEdge>> {
814 let edges = self.edges_tbl();
815 let mut c = self.lock();
816 let dir_clause = match dir {
817 EdgeDirection::Out => "source = $1",
818 EdgeDirection::In => "target = $1",
819 EdgeDirection::Both => "(source = $1 OR target = $1)",
820 };
821 let rows = if let Some(r) = relation {
822 let sql = format!(
823 "SELECT id, source, target, relation, weight, ts FROM {edges} \
824 WHERE {dir_clause} AND relation = $2 ORDER BY weight DESC"
825 );
826 c.query(&sql, &[&node_id, &r]).map_err(pg_err)?
827 } else {
828 let sql = format!(
829 "SELECT id, source, target, relation, weight, ts FROM {edges} \
830 WHERE {dir_clause} ORDER BY weight DESC"
831 );
832 c.query(&sql, &[&node_id]).map_err(pg_err)?
833 };
834 Ok(rows.iter().map(row_to_edge).collect())
835 }
836
837 fn neighbors_weighted(
838 &self,
839 seed_ids: &[String],
840 dir: EdgeDirection,
841 relation: Option<&str>,
842 ) -> Result<Vec<(String, f64)>> {
843 if seed_ids.is_empty() {
844 return Ok(vec![]);
845 }
846 let edges = self.edges_tbl();
847 const MAX_SEEDS: usize = 100;
848 let seed_ids = if seed_ids.len() > MAX_SEEDS {
849 &seed_ids[..MAX_SEEDS]
850 } else {
851 seed_ids
852 };
853 let seed_arr: Vec<String> = seed_ids.to_vec();
854 let seed_set: HashSet<&str> = seed_ids.iter().map(String::as_str).collect();
855 let mut c = self.lock();
856 let mut weights: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
857
858 let halves: &[&str] = match dir {
860 EdgeDirection::Out => &["source"],
861 EdgeDirection::In => &["target"],
862 EdgeDirection::Both => &["source", "target"],
863 };
864 for &follow in halves {
865 let select_col = if follow == "source" {
868 "target"
869 } else {
870 "source"
871 };
872 let rel_clause = relation.map(|_| " AND relation = $2").unwrap_or("");
873 let sql = format!(
874 "SELECT {select_col} AS nb, SUM(weight) AS w FROM {edges} \
875 WHERE {follow} = ANY($1){rel_clause} GROUP BY {select_col}"
876 );
877 let rows = if let Some(r) = relation {
878 c.query(&sql, &[&seed_arr, &r]).map_err(pg_err)?
879 } else {
880 c.query(&sql, &[&seed_arr]).map_err(pg_err)?
881 };
882 for row in &rows {
883 let nb: String = row.get(0);
884 let w: f64 = row.get(1);
885 if !seed_set.contains(nb.as_str()) {
886 *weights.entry(nb).or_default() += w;
887 }
888 }
889 }
890
891 let mut result: Vec<(String, f64)> = weights.into_iter().collect();
892 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
893 Ok(result)
894 }
895
896 fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
897 let edges = self.edges_tbl();
898 let mut c = self.lock();
899 let params: [&(dyn ToSql + Sync); 1] = [&node_id];
900 let rows = c
901 .query(
902 &format!(
903 "SELECT id, source, target, relation, weight, ts FROM {edges} WHERE source = $1 OR target = $1"
904 ),
905 ¶ms,
906 )
907 .map_err(pg_err)?;
908 Ok(rows.iter().map(row_to_edge).collect())
909 }
910
911 fn delete_edge(&self, id: &str) -> Result<bool> {
912 let edges = self.edges_tbl();
913 let mut c = self.lock();
914 let params: [&(dyn ToSql + Sync); 1] = [&id];
915 let n = c
916 .execute(&format!("DELETE FROM {edges} WHERE id = $1"), ¶ms)
917 .map_err(pg_err)?;
918 Ok(n > 0)
919 }
920
921 fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
922 let edges = self.edges_tbl();
923 let mut c = self.lock();
924 let params: [&(dyn ToSql + Sync); 1] = [&node_id];
925 c.execute(
926 &format!("DELETE FROM {edges} WHERE source = $1 OR target = $1"),
927 ¶ms,
928 )
929 .map_err(pg_err)?;
930 Ok(())
931 }
932
933 fn current_version(&self) -> Result<u32> {
934 let meta = self.meta_tbl();
935 let mut c = self.lock();
936 let row = c
937 .query_opt(
938 &format!("SELECT value FROM {meta} WHERE key = 'graph_schema_version'"),
939 &[],
940 )
941 .map_err(pg_err)?;
942 Ok(row
943 .map(|r| r.get::<_, String>(0))
944 .and_then(|s| s.parse().ok())
945 .unwrap_or(0))
946 }
947
948 fn migrate(&self) -> Result<u32> {
949 let mut c = self.lock();
950 let current = schema_version(&mut c, &self.table_prefix)?;
951 migrate(&mut c, current, &self.table_prefix)
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use crate::graph::{GraphBackend, SqliteGraph};
959 use postgres::{Config, NoTls};
960
961 const TEST_DB: &str = "llm_kernel_pg_test";
962
963 fn sample_node(id: &str) -> GraphNode {
964 GraphNode {
965 id: id.to_string(),
966 node_type: "concept".to_string(),
967 title: format!("Node {id}"),
968 body: "pg backend test body".to_string(),
969 tags: vec!["backend".to_string()],
970 projects: vec![],
971 agents: vec![],
972 created: "2026-01-01T00:00:00Z".to_string(),
973 updated: "2026-01-01T00:00:00Z".to_string(),
974 importance: 0.5,
975 access_count: 0,
976 accessed_at: String::new(),
977 }
978 }
979
980 fn with_test_db<F: FnOnce(&PgGraph)>(body: F) {
982 let base = std::env::var("LLMKERNEL_PG_URL").expect("LLMKERNEL_PG_URL set");
983 let admin_cfg: Config = base
984 .parse()
985 .expect("LLMKERNEL_PG_URL is a valid libpq connstring");
986 {
987 let mut admin = admin_cfg.connect(NoTls).expect("admin connect");
988 let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {TEST_DB}"));
989 admin
990 .batch_execute(&format!("CREATE DATABASE {TEST_DB}"))
991 .expect("create test db");
992 }
993 let mut test_cfg = admin_cfg.clone();
994 test_cfg.dbname(TEST_DB);
995 let graph = PgGraph::connect_config(&test_cfg).expect("connect to test db");
996 body(&graph);
997 drop(graph);
998 let mut admin = admin_cfg.connect(NoTls).expect("admin reconnect");
999 let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {TEST_DB}"));
1000 }
1001
1002 #[test]
1005 fn search_patterns_escapes_and_wraps() {
1006 assert!(search_patterns("").is_empty());
1007 assert_eq!(search_patterns("rust"), vec!["%rust%".to_string()]);
1008 assert_eq!(search_patterns("100%"), vec!["%100\\%%".to_string()]);
1010 assert_eq!(search_patterns("a_b"), vec!["%a\\_b%".to_string()]);
1011 assert_eq!(
1013 search_patterns("rust db"),
1014 vec!["%rust%".to_string(), "%db%".to_string()]
1015 );
1016 }
1017
1018 #[test]
1023 fn is_identifier_safe_validation() {
1024 assert!(is_identifier_safe(""));
1026 assert!(is_identifier_safe("lk_"));
1027 assert!(is_identifier_safe("graph1"));
1028 assert!(is_identifier_safe("_x"));
1029 assert!(is_identifier_safe("ABC_123"));
1030 assert!(!is_identifier_safe("1lk"));
1032 assert!(!is_identifier_safe("lk nodes"));
1034 assert!(!is_identifier_safe("lk;"));
1035 assert!(!is_identifier_safe("lk' OR 1=1--"));
1036 assert!(!is_identifier_safe("lk-bad"));
1037 assert!(!is_identifier_safe("lk.bad"));
1038 assert!(!is_identifier_safe("데이터"));
1039 }
1040
1041 #[cfg(feature = "graph-pg-tls")]
1047 #[test]
1048 fn connect_native_tls_returns_result_not_panic() {
1049 let base = match std::env::var("LLMKERNEL_PG_URL") {
1050 Ok(u) => u,
1051 Err(_) => {
1052 eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1053 return;
1054 }
1055 };
1056 match PgGraph::connect_native_tls(&base) {
1057 Ok(g) => {
1058 assert_eq!(g.current_version().unwrap(), GRAPH_SCHEMA_VERSION);
1059 }
1060 Err(e) => {
1061 eprintln!("connect_native_tls returned Err as expected on a non-TLS server: {e}");
1062 }
1063 }
1064 }
1065
1066 #[test]
1069 fn live_pg_graph_backend_conformance() {
1070 if std::env::var("LLMKERNEL_PG_URL").is_err() {
1071 eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1072 return;
1073 }
1074 with_test_db(|g| {
1075 assert_eq!(g.current_version().unwrap(), GRAPH_SCHEMA_VERSION);
1076 assert_eq!(g.migrate().unwrap(), GRAPH_SCHEMA_VERSION);
1077
1078 let dyn_g: &dyn GraphBackend = g;
1079 assert!(dyn_g.read_node("n1").unwrap().is_none());
1080
1081 g.upsert_node(&sample_node("rust")).unwrap();
1082 let loaded = g.read_node("rust").unwrap().unwrap();
1083 assert_eq!(loaded.title, "Node rust");
1084 assert_eq!(loaded.tags, vec!["backend".to_string()]);
1085
1086 let mut updated = sample_node("rust");
1087 updated.title = "Rust ownership".into();
1088 updated.body = "borrow checker rules".into();
1089 g.upsert_node(&updated).unwrap();
1090 assert_eq!(
1091 g.read_node("rust").unwrap().unwrap().title,
1092 "Rust ownership"
1093 );
1094
1095 assert!(g.delete_node("rust").unwrap());
1096 assert!(!g.delete_node("rust").unwrap());
1097 assert!(g.read_node("rust").unwrap().is_none());
1098
1099 let mut n = sample_node("rust");
1100 n.title = "Rust ownership model".into();
1101 n.body = "borrow checker rules".into();
1102 n.tags = vec!["rust".into(), "memory".into()];
1103 g.upsert_node(&n).unwrap();
1104 let mut other = sample_node("py");
1105 other.title = "Python GIL".into();
1106 g.upsert_node(&other).unwrap();
1107
1108 let hits = g.search_nodes("rust", 10).unwrap();
1109 assert_eq!(hits.len(), 1);
1110 assert_eq!(hits[0].id, "rust");
1111
1112 let tagged = g.query_nodes(Some("rust"), None, None, 10).unwrap();
1113 assert_eq!(tagged.len(), 1);
1114 assert_eq!(tagged[0].id, "rust");
1115
1116 g.append_edge(&GraphEdge {
1117 id: "e1".into(),
1118 source: "rust".into(),
1119 target: "py".into(),
1120 relation: "related".into(),
1121 weight: 1.0,
1122 ts: "2026-01-01T00:00:00Z".into(),
1123 })
1124 .unwrap();
1125 assert_eq!(g.edges_for_node("rust").unwrap().len(), 1);
1126 assert!(
1127 g.related_nodes("rust", 2)
1128 .unwrap()
1129 .contains(&"py".to_string())
1130 );
1131
1132 g.append_edge(&GraphEdge {
1137 id: "e1dup".into(),
1138 source: "rust".into(),
1139 target: "py".into(),
1140 relation: "related".into(),
1141 weight: 2.0,
1142 ts: "2026-01-02T00:00:00Z".into(),
1143 })
1144 .unwrap();
1145 assert_eq!(
1146 g.edges_for_node("rust").unwrap().len(),
1147 1,
1148 "duplicate (src,tgt,rel) edge ignored"
1149 );
1150
1151 g.append_edge(&GraphEdge {
1155 id: "eloop".into(),
1156 source: "rust".into(),
1157 target: "rust".into(),
1158 relation: "self".into(),
1159 weight: 1.0,
1160 ts: "2026-01-01T00:00:00Z".into(),
1161 })
1162 .unwrap();
1163 let related = g.related_nodes("rust", 2).unwrap();
1164 assert!(
1165 !related.contains(&"rust".to_string()),
1166 "start node excluded even with a self-loop"
1167 );
1168
1169 let recalled = g.smart_recall(None, Some("ownership"), 5).unwrap();
1170 assert!(recalled.iter().any(|s| s.node.id == "rust"));
1171 let after = g.read_node("rust").unwrap().unwrap();
1172 assert!(after.access_count >= 1, "access_count incremented");
1173 });
1174 }
1175
1176 #[test]
1180 fn live_prefix_isolation() {
1181 if std::env::var("LLMKERNEL_PG_URL").is_err() {
1182 eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1183 return;
1184 }
1185 with_test_db(|g_default| {
1186 g_default.upsert_node(&sample_node("default_only")).unwrap();
1188 assert_eq!(
1189 g_default.read_node("default_only").unwrap().unwrap().title,
1190 "Node default_only"
1191 );
1192
1193 let base = std::env::var("LLMKERNEL_PG_URL").unwrap();
1195 let admin_cfg: Config = base.parse().expect("valid connstring");
1196 let mut cfg = admin_cfg.clone();
1197 cfg.dbname(TEST_DB);
1198 let g_prefixed =
1199 PgGraph::connect_config_with_prefix(&cfg, "lk_").expect("prefixed connect");
1200
1201 assert!(g_prefixed.read_node("default_only").unwrap().is_none());
1203 g_prefixed.upsert_node(&sample_node("lk_only")).unwrap();
1204 assert!(g_default.read_node("lk_only").unwrap().is_none());
1205 assert_eq!(
1206 g_prefixed.read_node("lk_only").unwrap().unwrap().title,
1207 "Node lk_only"
1208 );
1209
1210 g_prefixed.upsert_node(&sample_node("lk_peer")).unwrap();
1212 g_prefixed
1213 .append_edge(&GraphEdge {
1214 id: "lk_e1".into(),
1215 source: "lk_only".into(),
1216 target: "lk_peer".into(),
1217 relation: "related".into(),
1218 weight: 1.0,
1219 ts: "2026-01-01T00:00:00Z".into(),
1220 })
1221 .unwrap();
1222 assert_eq!(g_prefixed.edges_for_node("lk_only").unwrap().len(), 1);
1223 assert_eq!(g_default.edges_for_node("lk_only").unwrap().len(), 0);
1224
1225 assert!(g_prefixed.current_version().unwrap() >= 2);
1227
1228 assert!(PgGraph::connect_config_with_prefix(&cfg, "lk; drop").is_err());
1230 assert!(PgGraph::connect_config_with_prefix(&cfg, "1lk").is_err());
1231 });
1232 }
1233
1234 #[test]
1237 fn live_migrate_sqlite_to_postgres_round_trip() {
1238 let base = match std::env::var("LLMKERNEL_PG_URL") {
1239 Ok(u) => u,
1240 Err(_) => {
1241 eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1242 return;
1243 }
1244 };
1245
1246 let src = SqliteGraph::open_in_memory().expect("sqlite source");
1247 let mut a = sample_node("a");
1248 a.body = "migrate test body".into();
1249 a.tags = vec!["migrate".to_string()];
1250 a.projects = vec!["demo".to_string()];
1251 a.importance = 0.6;
1252 src.upsert_node(&a).unwrap();
1253 src.upsert_node(&sample_node("b")).unwrap();
1254 src.append_edge(&GraphEdge {
1255 id: "e1".into(),
1256 source: "a".into(),
1257 target: "b".into(),
1258 relation: "related".into(),
1259 weight: 1.0,
1260 ts: "2026-01-01T00:00:00Z".into(),
1261 })
1262 .unwrap();
1263 let nodes = src.query_nodes(None, None, None, 200).unwrap();
1264 assert_eq!(nodes.len(), 2);
1265
1266 let admin_cfg: Config = base.parse().expect("valid connstring");
1267 {
1268 let mut admin = admin_cfg.connect(NoTls).expect("admin connect");
1269 let _ = admin.batch_execute("DROP DATABASE IF EXISTS llm_kernel_migrate_test");
1270 admin
1271 .batch_execute("CREATE DATABASE llm_kernel_migrate_test")
1272 .expect("create test db");
1273 }
1274 let mut test_cfg = admin_cfg.clone();
1275 test_cfg.dbname("llm_kernel_migrate_test");
1276 let pg = PgGraph::connect_config(&test_cfg).expect("connect target");
1277
1278 for n in &nodes {
1279 pg.upsert_node(n).unwrap();
1280 }
1281 pg.append_edge(&GraphEdge {
1282 id: "e1".into(),
1283 source: "a".into(),
1284 target: "b".into(),
1285 relation: "related".into(),
1286 weight: 1.0,
1287 ts: "2026-01-01T00:00:00Z".into(),
1288 })
1289 .unwrap();
1290
1291 assert_eq!(pg.list_nodes(100).unwrap().len(), 2);
1292 assert_eq!(pg.list_edges(100).unwrap().len(), 1);
1293 let loaded = pg.read_node("a").unwrap().unwrap();
1294 assert_eq!(loaded.title, "Node a");
1295 assert_eq!(loaded.tags, vec!["migrate".to_string()]);
1296 assert!((loaded.importance - 0.6).abs() < 1e-9);
1297
1298 drop(pg);
1299 let mut admin = admin_cfg.connect(NoTls).expect("admin reconnect");
1300 let _ = admin.batch_execute("DROP DATABASE IF EXISTS llm_kernel_migrate_test");
1301 }
1302}