1use std::collections::HashSet;
31use std::sync::{Mutex, MutexGuard};
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use postgres::row::Row;
35use postgres::types::ToSql;
36use postgres::{Client, Config, NoTls};
37
38use super::algo::{CsrGraph, pagerank_default};
39use super::lifecycle::now_iso;
40use super::recall::{W_ACCESS, W_FTS, W_GRAPH, W_IMPORTANCE, W_RECENCY, compute_recency};
41use super::schema::GRAPH_SCHEMA_VERSION;
42use super::types::{escape_like, join_csv, split_csv};
43use super::{GraphBackend, GraphEdge, GraphNode, ScoredNode};
44use crate::error::{KernelError, Result};
45
46const NODE_COLUMNS: &str = "id, node_type, title, tags, projects, agents, \
48 created, updated, body, importance, access_count, accessed_at";
49
50fn pg_err(e: postgres::Error) -> KernelError {
52 KernelError::Store(format!("postgres: {e:?}"))
53}
54
55fn row_to_node(row: &Row) -> GraphNode {
57 GraphNode {
58 id: row.get(0),
59 node_type: row.get(1),
60 title: row.get(2),
61 tags: split_csv(&row.get::<_, String>(3)),
62 projects: split_csv(&row.get::<_, String>(4)),
63 agents: split_csv(&row.get::<_, String>(5)),
64 created: row.get(6),
65 updated: row.get(7),
66 body: row.get(8),
67 importance: row.get(9),
68 access_count: row.get(10),
69 accessed_at: row.get(11),
70 }
71}
72
73fn row_to_edge(row: &Row) -> GraphEdge {
77 GraphEdge {
78 id: row.get(0),
79 source: row.get(1),
80 target: row.get(2),
81 relation: row.get(3),
82 weight: row.get(4),
83 ts: row.get(5),
84 }
85}
86
87fn search_patterns(query: &str) -> Vec<String> {
91 query
92 .split_whitespace()
93 .map(|t| format!("%{}%", escape_like(t)))
94 .collect()
95}
96
97fn init_schema(client: &mut Client) -> Result<()> {
99 client
100 .batch_execute(
101 "CREATE TABLE IF NOT EXISTS nodes (
102 id TEXT PRIMARY KEY,
103 node_type TEXT NOT NULL,
104 title TEXT NOT NULL,
105 tags TEXT NOT NULL DEFAULT '',
106 projects TEXT NOT NULL DEFAULT '',
107 agents TEXT NOT NULL DEFAULT '',
108 created TEXT NOT NULL,
109 updated TEXT NOT NULL,
110 body TEXT NOT NULL DEFAULT '',
111 importance DOUBLE PRECISION NOT NULL DEFAULT 0.5,
112 access_count BIGINT NOT NULL DEFAULT 0,
113 accessed_at TEXT NOT NULL DEFAULT ''
114 );
115 CREATE TABLE IF NOT EXISTS edges (
116 id TEXT PRIMARY KEY,
117 source TEXT NOT NULL,
118 target TEXT NOT NULL,
119 relation TEXT NOT NULL DEFAULT 'related',
120 weight DOUBLE PRECISION NOT NULL DEFAULT 1.0,
121 ts TEXT NOT NULL
122 );
123 CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source);
124 CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target);
125 CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_src_tgt_rel ON edges(source, target, relation);
126 CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(node_type);
127 CREATE INDEX IF NOT EXISTS idx_nodes_updated ON nodes(updated DESC);
128 CREATE INDEX IF NOT EXISTS idx_nodes_importance ON nodes(importance DESC);
129 CREATE INDEX IF NOT EXISTS idx_nodes_accessed ON nodes(accessed_at DESC);
130 CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created);
131 CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
132 INSERT INTO _meta (key, value) VALUES ('graph_schema_version', '2')
133 ON CONFLICT (key) DO NOTHING;",
134 )
135 .map_err(pg_err)?;
136 Ok(())
137}
138
139fn schema_version(client: &mut Client) -> Result<u32> {
141 let row = client
142 .query_opt(
143 "SELECT value FROM _meta WHERE key = 'graph_schema_version'",
144 &[],
145 )
146 .map_err(pg_err)?;
147 Ok(row
148 .map(|r| r.get::<_, String>(0))
149 .and_then(|s| s.parse().ok())
150 .unwrap_or(0))
151}
152
153fn migrate(client: &mut Client, current: u32) -> Result<u32> {
158 if current >= GRAPH_SCHEMA_VERSION {
159 return Ok(current);
160 }
161 let mut tx = client.transaction().map_err(pg_err)?;
162 let mut v = current;
163 if v < 2 {
164 tx.batch_execute("CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created);")
165 .map_err(pg_err)?;
166 v = 2;
167 }
168 tx.execute(
169 "UPDATE _meta SET value = $1 WHERE key = 'graph_schema_version'",
170 &[&v.to_string()],
171 )
172 .map_err(pg_err)?;
173 tx.commit().map_err(pg_err)?;
174 Ok(v)
175}
176
177pub struct PgGraph {
182 client: Mutex<Client>,
183}
184
185impl PgGraph {
186 pub fn connect(url: &str) -> Result<Self> {
189 Self::connect_config(&Self::parse_config(url)?)
190 }
191
192 pub fn connect_config(config: &Config) -> Result<Self> {
195 let client = config.connect(NoTls).map_err(pg_err)?;
196 Self::from_client(client)
197 }
198
199 #[cfg(feature = "graph-pg-tls")]
204 pub fn connect_tls<T>(url: &str, connector: T) -> Result<Self>
205 where
206 T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
207 T::TlsConnect: Send,
208 T::Stream: Send,
209 <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
210 {
211 Self::connect_config_tls(&Self::parse_config(url)?, connector)
212 }
213
214 #[cfg(feature = "graph-pg-tls")]
218 pub fn connect_config_tls<T>(config: &Config, connector: T) -> Result<Self>
219 where
220 T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
221 T::TlsConnect: Send,
222 T::Stream: Send,
223 <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
224 {
225 let client = config.connect(connector).map_err(pg_err)?;
226 Self::from_client(client)
227 }
228
229 #[cfg(feature = "graph-pg-tls")]
237 pub fn connect_native_tls(url: &str) -> Result<Self> {
238 let tls = native_tls::TlsConnector::new()
239 .map_err(|e| KernelError::Store(format!("native-tls connector: {e}")))?;
240 Self::connect_tls(url, postgres_native_tls::MakeTlsConnector::new(tls))
241 }
242
243 fn parse_config(url: &str) -> Result<Config> {
246 url.parse()
247 .map_err(|e| KernelError::Store(format!("invalid postgres config: {e}")))
248 }
249
250 fn from_client(mut client: Client) -> Result<Self> {
252 init_schema(&mut client)?;
253 let current = schema_version(&mut client)?;
254 migrate(&mut client, current)?;
255 Ok(Self {
256 client: Mutex::new(client),
257 })
258 }
259
260 fn lock(&self) -> MutexGuard<'_, Client> {
261 self.client.lock().unwrap_or_else(|e| e.into_inner())
262 }
263
264 pub fn list_nodes(&self, limit: usize) -> Result<Vec<GraphNode>> {
268 let mut c = self.lock();
269 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes ORDER BY updated DESC LIMIT {limit}");
270 let rows = c.query(&sql, &[]).map_err(pg_err)?;
271 Ok(rows.iter().map(row_to_node).collect())
272 }
273
274 pub fn list_edges(&self, limit: usize) -> Result<Vec<GraphEdge>> {
276 let mut c = self.lock();
277 let rows = c
278 .query(
279 "SELECT id, source, target, relation, weight, ts FROM edges LIMIT $1",
280 &[&(limit as i64)],
281 )
282 .map_err(pg_err)?;
283 Ok(rows.iter().map(row_to_edge).collect())
284 }
285}
286
287impl GraphBackend for PgGraph {
288 fn upsert_node(&self, node: &GraphNode) -> Result<()> {
289 let tags = join_csv(&node.tags);
290 let projects = join_csv(&node.projects);
291 let agents = join_csv(&node.agents);
292 let params: [&(dyn ToSql + Sync); 12] = [
293 &node.id,
294 &node.node_type,
295 &node.title,
296 &tags,
297 &projects,
298 &agents,
299 &node.created,
300 &node.updated,
301 &node.body,
302 &node.importance,
303 &node.access_count,
304 &node.accessed_at,
305 ];
306 let mut c = self.lock();
307 c.execute(
308 "INSERT INTO nodes (id, node_type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at)
309 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
310 ON CONFLICT (id) DO UPDATE SET
311 node_type=EXCLUDED.node_type, title=EXCLUDED.title, tags=EXCLUDED.tags,
312 projects=EXCLUDED.projects, agents=EXCLUDED.agents, created=EXCLUDED.created,
313 updated=EXCLUDED.updated, body=EXCLUDED.body, importance=EXCLUDED.importance,
314 access_count=EXCLUDED.access_count, accessed_at=EXCLUDED.accessed_at",
315 ¶ms,
316 )
317 .map_err(pg_err)?;
318 Ok(())
319 }
320
321 fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
322 let mut c = self.lock();
323 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = $1");
324 let params: [&(dyn ToSql + Sync); 1] = [&id];
325 let row = c.query_opt(&sql, ¶ms).map_err(pg_err)?;
326 Ok(row.as_ref().map(row_to_node))
327 }
328
329 fn delete_node(&self, id: &str) -> Result<bool> {
330 let mut c = self.lock();
331 let n = c
332 .execute("DELETE FROM nodes WHERE id = $1", &[&id])
333 .map_err(pg_err)?;
334 Ok(n > 0)
335 }
336
337 fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
338 let terms = search_patterns(query);
339 if terms.is_empty() {
340 return Ok(vec![]);
341 }
342 let mut conds: Vec<String> = Vec::with_capacity(terms.len());
343 let params: Vec<&(dyn ToSql + Sync)> =
344 terms.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
345 for i in 0..terms.len() {
346 conds.push(format!(
347 "(title || ' ' || body || ' ' || tags) ILIKE ${n} ESCAPE '\\'",
348 n = i + 1
349 ));
350 }
351 let where_clause = conds.join(" AND ");
352 let sql = format!(
353 "SELECT {NODE_COLUMNS} FROM nodes WHERE {where_clause} ORDER BY importance DESC, updated DESC LIMIT {limit}"
354 );
355 let mut c = self.lock();
356 let rows = c.query(&sql, ¶ms).map_err(pg_err)?;
357 Ok(rows.iter().map(row_to_node).collect())
358 }
359
360 fn query_nodes(
361 &self,
362 tag: Option<&str>,
363 node_type: Option<&str>,
364 project: Option<&str>,
365 limit: usize,
366 ) -> Result<Vec<GraphNode>> {
367 let limit = limit.min(200) as i64;
368 let mut owned: Vec<String> = Vec::new();
369 let mut conds: Vec<String> = Vec::new();
370 if let Some(t) = tag {
371 owned.push(escape_like(t));
372 conds.push(format!(
373 "(',' || tags || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
374 n = owned.len()
375 ));
376 }
377 if let Some(nt) = node_type {
378 owned.push(nt.to_string());
379 conds.push(format!("node_type = ${n}", n = owned.len()));
380 }
381 if let Some(p) = project {
382 owned.push(escape_like(p));
383 conds.push(format!(
384 "(',' || projects || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
385 n = owned.len()
386 ));
387 }
388 let where_clause = if conds.is_empty() {
389 String::new()
390 } else {
391 format!("WHERE {}", conds.join(" AND "))
392 };
393 let params: Vec<&(dyn ToSql + Sync)> =
394 owned.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
395 let sql = format!(
396 "SELECT {NODE_COLUMNS} FROM nodes {where_clause} ORDER BY updated DESC LIMIT {limit}"
397 );
398 let mut c = self.lock();
399 let rows = c.query(&sql, ¶ms).map_err(pg_err)?;
400 Ok(rows.iter().map(row_to_node).collect())
401 }
402
403 fn smart_recall(
404 &self,
405 project: Option<&str>,
406 hint: Option<&str>,
407 limit: usize,
408 ) -> Result<Vec<ScoredNode>> {
409 let now_secs = SystemTime::now()
410 .duration_since(UNIX_EPOCH)
411 .unwrap_or_default()
412 .as_secs();
413
414 let fts_ids: HashSet<String> = match hint {
416 Some(h) if !h.is_empty() => self
417 .search_nodes(h, limit * 4)?
418 .into_iter()
419 .map(|n| n.id)
420 .collect(),
421 _ => HashSet::new(),
422 };
423
424 let candidate_limit = (limit * 4).max(40) as i64;
426 let mut owned: Vec<String> = Vec::new();
427 let mut conds: Vec<String> = vec!["(',' || tags || ',') NOT ILIKE '%,stale,%'".to_string()];
428 if let Some(p) = project {
429 owned.push(escape_like(p));
430 conds.push(format!(
431 "(',' || projects || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
432 n = owned.len()
433 ));
434 }
435 let where_clause = conds.join(" AND ");
436 let params: Vec<&(dyn ToSql + Sync)> =
437 owned.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
438 let sql = format!(
439 "SELECT {NODE_COLUMNS} FROM nodes WHERE {where_clause} ORDER BY importance DESC, updated DESC LIMIT {candidate_limit}"
440 );
441 let mut c = self.lock();
442 let rows = c.query(&sql, ¶ms).map_err(pg_err)?;
443 let candidates: Vec<GraphNode> = rows.iter().map(row_to_node).collect();
444
445 let mut scored: Vec<ScoredNode> = candidates
447 .into_iter()
448 .map(|node| {
449 let recency = compute_recency(&node.updated, now_secs);
450 let importance = node.importance;
451 let access_freq = (node.access_count.max(0) as f64 / 20.0).min(1.0);
452 let fts_match = if fts_ids.contains(&node.id) { 1.0 } else { 0.0 };
453 let score = W_RECENCY * recency
454 + W_IMPORTANCE * importance
455 + W_ACCESS * access_freq
456 + W_FTS * fts_match;
457 ScoredNode { node, score }
458 })
459 .collect();
460 scored.sort_by(|a, b| {
461 b.score
462 .partial_cmp(&a.score)
463 .unwrap_or(std::cmp::Ordering::Equal)
464 });
465 scored.truncate(limit);
466
467 if scored.len() > 1 {
471 const MAX_GRAPH_BOOST_PARTICIPANTS: usize = 100;
472 let candidate_ids: Vec<String> = scored
473 .iter()
474 .take(MAX_GRAPH_BOOST_PARTICIPANTS)
475 .map(|sn| sn.node.id.clone())
476 .collect();
477 let n = candidate_ids.len();
478 let l1: String = (1..=n)
479 .map(|i| format!("${i}"))
480 .collect::<Vec<_>>()
481 .join(",");
482 let l2: String = ((n + 1)..=(2 * n))
483 .map(|i| format!("${i}"))
484 .collect::<Vec<_>>()
485 .join(",");
486 let sql = format!(
487 "SELECT id, source, target, relation, weight, ts FROM edges WHERE source IN ({l1}) AND target IN ({l2})"
488 );
489 let mut bp: Vec<&(dyn ToSql + Sync)> = Vec::with_capacity(2 * n);
490 for id in &candidate_ids {
491 bp.push(id);
492 }
493 for id in &candidate_ids {
494 bp.push(id);
495 }
496 let sub_edges: Vec<GraphEdge> = match c.query(&sql, &bp) {
497 Ok(rows) => rows
498 .iter()
499 .map(|r| GraphEdge {
500 id: r.get(0),
501 source: r.get(1),
502 target: r.get(2),
503 relation: r.get(3),
504 weight: r.get(4),
505 ts: r.get(5),
506 })
507 .collect(),
508 Err(_) => Vec::new(),
509 };
510 let csr = CsrGraph::from_edges(&candidate_ids, &sub_edges);
511 let pr = pagerank_default(&csr);
512 let max_pr = pr.iter().copied().fold(0.0_f64, f64::max).max(1e-12);
513 let pr_map: std::collections::HashMap<String, f64> = candidate_ids
514 .iter()
515 .zip(pr.iter())
516 .map(|(id, &s)| (id.clone(), s / max_pr))
517 .collect();
518 for sn in &mut scored {
519 let boost = pr_map.get(&sn.node.id).copied().unwrap_or(0.0);
520 sn.score += W_GRAPH * boost;
521 }
522 scored.sort_by(|a, b| {
523 b.score
524 .partial_cmp(&a.score)
525 .unwrap_or(std::cmp::Ordering::Equal)
526 });
527 }
528
529 if !scored.is_empty() {
532 let now = now_iso();
533 let ids: Vec<&str> = scored.iter().map(|sn| sn.node.id.as_str()).collect();
534 let placeholders: String = (0..ids.len())
535 .map(|i| format!("${}", i + 2))
536 .collect::<Vec<_>>()
537 .join(",");
538 let mut params: Vec<&(dyn ToSql + Sync)> = Vec::with_capacity(ids.len() + 1);
539 params.push(&now);
540 for id in &ids {
541 params.push(id);
542 }
543 let sql = format!(
544 "UPDATE nodes SET access_count = access_count + 1, accessed_at = $1 WHERE id IN ({placeholders})"
545 );
546 let _ = c.execute(&sql, ¶ms);
547 }
548
549 Ok(scored)
550 }
551
552 fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
553 let mut c = self.lock();
554 let depth_v = depth as i32;
555 let params: [&(dyn ToSql + Sync); 2] = [&start_id, &depth_v];
556 let rows = c
557 .query(
558 "WITH RECURSIVE bfs(node_id, lvl) AS (
562 SELECT nb.node_id, 1 FROM (
563 SELECT target AS node_id FROM edges WHERE source = $1
564 UNION
565 SELECT source AS node_id FROM edges WHERE target = $1
566 ) nb
567 UNION
568 SELECT CASE WHEN e.source = bfs.node_id THEN e.target ELSE e.source END,
569 bfs.lvl + 1
570 FROM bfs
571 JOIN edges e ON e.source = bfs.node_id OR e.target = bfs.node_id
572 WHERE bfs.lvl < $2
573 )
574 SELECT DISTINCT node_id FROM bfs WHERE node_id <> $1 LIMIT 500",
575 ¶ms,
576 )
577 .map_err(pg_err)?;
578 Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect())
579 }
580
581 fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
582 let params: [&(dyn ToSql + Sync); 6] = [
583 &edge.id,
584 &edge.source,
585 &edge.target,
586 &edge.relation,
587 &edge.weight,
588 &edge.ts,
589 ];
590 let mut c = self.lock();
591 c.execute(
592 "INSERT INTO edges (id, source, target, relation, weight, ts)
593 VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING",
594 ¶ms,
595 )
596 .map_err(pg_err)?;
597 Ok(())
598 }
599
600 fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
601 let mut c = self.lock();
602 let params: [&(dyn ToSql + Sync); 1] = [&node_id];
603 let rows = c
604 .query(
605 "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = $1 OR target = $1",
606 ¶ms,
607 )
608 .map_err(pg_err)?;
609 Ok(rows.iter().map(row_to_edge).collect())
610 }
611
612 fn delete_edge(&self, id: &str) -> Result<bool> {
613 let mut c = self.lock();
614 let params: [&(dyn ToSql + Sync); 1] = [&id];
615 let n = c
616 .execute("DELETE FROM edges WHERE id = $1", ¶ms)
617 .map_err(pg_err)?;
618 Ok(n > 0)
619 }
620
621 fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
622 let mut c = self.lock();
623 let params: [&(dyn ToSql + Sync); 1] = [&node_id];
624 c.execute(
625 "DELETE FROM edges WHERE source = $1 OR target = $1",
626 ¶ms,
627 )
628 .map_err(pg_err)?;
629 Ok(())
630 }
631
632 fn current_version(&self) -> Result<u32> {
633 let mut c = self.lock();
634 schema_version(&mut c)
635 }
636
637 fn migrate(&self) -> Result<u32> {
638 let mut c = self.lock();
639 let current = schema_version(&mut c)?;
640 migrate(&mut c, current)
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::graph::{GraphBackend, SqliteGraph};
648 use postgres::{Config, NoTls};
649
650 const TEST_DB: &str = "llm_kernel_pg_test";
651
652 fn sample_node(id: &str) -> GraphNode {
653 GraphNode {
654 id: id.to_string(),
655 node_type: "concept".to_string(),
656 title: format!("Node {id}"),
657 body: "pg backend test body".to_string(),
658 tags: vec!["backend".to_string()],
659 projects: vec![],
660 agents: vec![],
661 created: "2026-01-01T00:00:00Z".to_string(),
662 updated: "2026-01-01T00:00:00Z".to_string(),
663 importance: 0.5,
664 access_count: 0,
665 accessed_at: String::new(),
666 }
667 }
668
669 fn with_test_db<F: FnOnce(&PgGraph)>(body: F) {
671 let base = std::env::var("LLMKERNEL_PG_URL").expect("LLMKERNEL_PG_URL set");
672 let admin_cfg: Config = base
673 .parse()
674 .expect("LLMKERNEL_PG_URL is a valid libpq connstring");
675 {
676 let mut admin = admin_cfg.connect(NoTls).expect("admin connect");
677 let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {TEST_DB}"));
678 admin
679 .batch_execute(&format!("CREATE DATABASE {TEST_DB}"))
680 .expect("create test db");
681 }
682 let mut test_cfg = admin_cfg.clone();
683 test_cfg.dbname(TEST_DB);
684 let graph = PgGraph::connect_config(&test_cfg).expect("connect to test db");
685 body(&graph);
686 drop(graph);
687 let mut admin = admin_cfg.connect(NoTls).expect("admin reconnect");
688 let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {TEST_DB}"));
689 }
690
691 #[test]
694 fn search_patterns_escapes_and_wraps() {
695 assert!(search_patterns("").is_empty());
696 assert_eq!(search_patterns("rust"), vec!["%rust%".to_string()]);
697 assert_eq!(search_patterns("100%"), vec!["%100\\%%".to_string()]);
699 assert_eq!(search_patterns("a_b"), vec!["%a\\_b%".to_string()]);
700 assert_eq!(
702 search_patterns("rust db"),
703 vec!["%rust%".to_string(), "%db%".to_string()]
704 );
705 }
706
707 #[cfg(feature = "graph-pg-tls")]
713 #[test]
714 fn connect_native_tls_returns_result_not_panic() {
715 let base = match std::env::var("LLMKERNEL_PG_URL") {
716 Ok(u) => u,
717 Err(_) => {
718 eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
719 return;
720 }
721 };
722 match PgGraph::connect_native_tls(&base) {
723 Ok(g) => {
724 assert_eq!(g.current_version().unwrap(), 2);
725 }
726 Err(e) => {
727 eprintln!("connect_native_tls returned Err as expected on a non-TLS server: {e}");
728 }
729 }
730 }
731
732 #[test]
735 fn live_pg_graph_backend_conformance() {
736 if std::env::var("LLMKERNEL_PG_URL").is_err() {
737 eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
738 return;
739 }
740 with_test_db(|g| {
741 assert_eq!(g.current_version().unwrap(), 2);
742 assert_eq!(g.migrate().unwrap(), 2);
743
744 let dyn_g: &dyn GraphBackend = g;
745 assert!(dyn_g.read_node("n1").unwrap().is_none());
746
747 g.upsert_node(&sample_node("rust")).unwrap();
748 let loaded = g.read_node("rust").unwrap().unwrap();
749 assert_eq!(loaded.title, "Node rust");
750 assert_eq!(loaded.tags, vec!["backend".to_string()]);
751
752 let mut updated = sample_node("rust");
753 updated.title = "Rust ownership".into();
754 updated.body = "borrow checker rules".into();
755 g.upsert_node(&updated).unwrap();
756 assert_eq!(
757 g.read_node("rust").unwrap().unwrap().title,
758 "Rust ownership"
759 );
760
761 assert!(g.delete_node("rust").unwrap());
762 assert!(!g.delete_node("rust").unwrap());
763 assert!(g.read_node("rust").unwrap().is_none());
764
765 let mut n = sample_node("rust");
766 n.title = "Rust ownership model".into();
767 n.body = "borrow checker rules".into();
768 n.tags = vec!["rust".into(), "memory".into()];
769 g.upsert_node(&n).unwrap();
770 let mut other = sample_node("py");
771 other.title = "Python GIL".into();
772 g.upsert_node(&other).unwrap();
773
774 let hits = g.search_nodes("rust", 10).unwrap();
775 assert_eq!(hits.len(), 1);
776 assert_eq!(hits[0].id, "rust");
777
778 let tagged = g.query_nodes(Some("rust"), None, None, 10).unwrap();
779 assert_eq!(tagged.len(), 1);
780 assert_eq!(tagged[0].id, "rust");
781
782 g.append_edge(&GraphEdge {
783 id: "e1".into(),
784 source: "rust".into(),
785 target: "py".into(),
786 relation: "related".into(),
787 weight: 1.0,
788 ts: "2026-01-01T00:00:00Z".into(),
789 })
790 .unwrap();
791 assert_eq!(g.edges_for_node("rust").unwrap().len(), 1);
792 assert!(
793 g.related_nodes("rust", 2)
794 .unwrap()
795 .contains(&"py".to_string())
796 );
797
798 g.append_edge(&GraphEdge {
803 id: "e1dup".into(),
804 source: "rust".into(),
805 target: "py".into(),
806 relation: "related".into(),
807 weight: 2.0,
808 ts: "2026-01-02T00:00:00Z".into(),
809 })
810 .unwrap();
811 assert_eq!(
812 g.edges_for_node("rust").unwrap().len(),
813 1,
814 "duplicate (src,tgt,rel) edge ignored"
815 );
816
817 g.append_edge(&GraphEdge {
821 id: "eloop".into(),
822 source: "rust".into(),
823 target: "rust".into(),
824 relation: "self".into(),
825 weight: 1.0,
826 ts: "2026-01-01T00:00:00Z".into(),
827 })
828 .unwrap();
829 let related = g.related_nodes("rust", 2).unwrap();
830 assert!(
831 !related.contains(&"rust".to_string()),
832 "start node excluded even with a self-loop"
833 );
834
835 let recalled = g.smart_recall(None, Some("ownership"), 5).unwrap();
836 assert!(recalled.iter().any(|s| s.node.id == "rust"));
837 let after = g.read_node("rust").unwrap().unwrap();
838 assert!(after.access_count >= 1, "access_count incremented");
839 });
840 }
841
842 #[test]
845 fn live_migrate_sqlite_to_postgres_round_trip() {
846 let base = match std::env::var("LLMKERNEL_PG_URL") {
847 Ok(u) => u,
848 Err(_) => {
849 eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
850 return;
851 }
852 };
853
854 let src = SqliteGraph::open_in_memory().expect("sqlite source");
855 let mut a = sample_node("a");
856 a.body = "migrate test body".into();
857 a.tags = vec!["migrate".to_string()];
858 a.projects = vec!["demo".to_string()];
859 a.importance = 0.6;
860 src.upsert_node(&a).unwrap();
861 src.upsert_node(&sample_node("b")).unwrap();
862 src.append_edge(&GraphEdge {
863 id: "e1".into(),
864 source: "a".into(),
865 target: "b".into(),
866 relation: "related".into(),
867 weight: 1.0,
868 ts: "2026-01-01T00:00:00Z".into(),
869 })
870 .unwrap();
871 let nodes = src.query_nodes(None, None, None, 200).unwrap();
872 assert_eq!(nodes.len(), 2);
873
874 let admin_cfg: Config = base.parse().expect("valid connstring");
875 {
876 let mut admin = admin_cfg.connect(NoTls).expect("admin connect");
877 let _ = admin.batch_execute("DROP DATABASE IF EXISTS llm_kernel_migrate_test");
878 admin
879 .batch_execute("CREATE DATABASE llm_kernel_migrate_test")
880 .expect("create test db");
881 }
882 let mut test_cfg = admin_cfg.clone();
883 test_cfg.dbname("llm_kernel_migrate_test");
884 let pg = PgGraph::connect_config(&test_cfg).expect("connect target");
885
886 for n in &nodes {
887 pg.upsert_node(n).unwrap();
888 }
889 pg.append_edge(&GraphEdge {
890 id: "e1".into(),
891 source: "a".into(),
892 target: "b".into(),
893 relation: "related".into(),
894 weight: 1.0,
895 ts: "2026-01-01T00:00:00Z".into(),
896 })
897 .unwrap();
898
899 assert_eq!(pg.list_nodes(100).unwrap().len(), 2);
900 assert_eq!(pg.list_edges(100).unwrap().len(), 1);
901 let loaded = pg.read_node("a").unwrap().unwrap();
902 assert_eq!(loaded.title, "Node a");
903 assert_eq!(loaded.tags, vec!["migrate".to_string()]);
904 assert!((loaded.importance - 0.6).abs() < 1e-9);
905
906 drop(pg);
907 let mut admin = admin_cfg.connect(NoTls).expect("admin reconnect");
908 let _ = admin.batch_execute("DROP DATABASE IF EXISTS llm_kernel_migrate_test");
909 }
910}