Skip to main content

llm_kernel/graph/
pg.rs

1//! PostgreSQL `GraphBackend` (`graph-pg` feature).
2//!
3//! `PgGraph` mirrors the bundled SQLite backend over a single mutex-guarded
4//! synchronous `postgres::Client`. Every `GraphBackend` method matches the
5//! SQLite semantics — the composite `smart_recall` reuses `super::recall`'s
6//! weights and `compute_recency` for zero drift across backends. Full-text
7//! search uses ILIKE substring matching (**no PostgreSQL extension required**,
8//! so the backend runs on any vanilla install), and schema versioning flows
9//! through the trait's `current_version` / `migrate`.
10//!
11//! # Performance note
12//!
13//! ILIKE with a leading wildcard (`'%term%'`) is a sequential scan — the BTREE
14//! indexes cannot serve it. This is intentional: keeping the backend
15//! extension-free preserves portability (the same rationale as the CJK feature).
16//! For very large graphs, callers may opt into indexed substring search by
17//! enabling `pg_trgm` out-of-band (`CREATE EXTENSION pg_trgm;
18//! CREATE INDEX nodes_trgm ON nodes USING gin ((title || ' ' || body || ' '
19//! || tags) gin_trgm_ops)`); the ILIKE queries then use it transparently.
20//!
21//! # TLS (`graph-pg-tls` feature)
22//!
23//! [`PgGraph::connect`] / [`PgGraph::connect_config`] always use
24//! `postgres::NoTls` — servers requiring `sslmode=require` or stricter reject
25//! that handshake. Enabling `graph-pg-tls` adds [`PgGraph::connect_native_tls`]
26//! (system trust store, one call) plus [`PgGraph::connect_tls`] /
27//! [`PgGraph::connect_config_tls`] for a caller-supplied
28//! `postgres::tls::MakeTlsConnect` implementor.
29
30use 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
46/// Standard node SELECT columns (positional order — keep in sync with [`row_to_node`]).
47const NODE_COLUMNS: &str = "id, node_type, title, tags, projects, agents, \
48     created, updated, body, importance, access_count, accessed_at";
49
50/// Map a `postgres` error into a [`KernelError::Store`].
51fn pg_err(e: postgres::Error) -> KernelError {
52    KernelError::Store(format!("postgres: {e:?}"))
53}
54
55/// Map a `nodes` SELECT row into a [`GraphNode`]. Column order matches [`NODE_COLUMNS`].
56fn 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
73/// Map an `edges` SELECT row into a [`GraphEdge`].
74///
75/// Column order: `id, source, target, relation, weight, ts`.
76fn 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
87/// Build escaped, wrapped ILIKE patterns for each whitespace-separated term in
88/// `query` — e.g. `"rust db"` → `["%rust%", "%db%"]`, `"100%"` → `["%100\\%%"]`.
89/// Pure (no connection) so the SQL-input transform is unit-testable offline.
90fn search_patterns(query: &str) -> Vec<String> {
91    query
92        .split_whitespace()
93        .map(|t| format!("%{}%", escape_like(t)))
94        .collect()
95}
96
97/// Create the graph schema if absent. Idempotent — safe on every connect.
98fn 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
139/// Recorded graph schema version from `_meta`, or `0` if unset.
140fn 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
153/// Apply pending migrations up to [`GRAPH_SCHEMA_VERSION`]. No-op when current.
154///
155/// Runs in a single transaction with rollback on failure — matching the SQLite
156/// `migrate_graph` semantics.
157fn 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
177/// PostgreSQL-backed `GraphBackend` over one mutex-guarded connection.
178///
179/// Opening applies the schema and runs pending migrations, matching
180/// `SqliteGraph::open` in the main crate.
181pub struct PgGraph {
182    client: Mutex<Client>,
183}
184
185impl PgGraph {
186    /// Connect to `url` (libpq connstring or `postgresql://` URL), apply schema
187    /// and migrations, and return a ready backend.
188    pub fn connect(url: &str) -> Result<Self> {
189        Self::connect_config(&Self::parse_config(url)?)
190    }
191
192    /// Connect from a pre-built [`Config`] (useful for overriding `dbname`,
193    /// e.g. when targeting a throwaway test database).
194    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    /// Connect to `url` using a caller-supplied TLS connector — for servers
200    /// requiring `sslmode=require` or stricter (e.g. RDS with
201    /// `rds.force_ssl`). See [`Self::connect_native_tls`] for the common case
202    /// of a system-trust-store `native-tls` connector.
203    #[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    /// Connect from a pre-built [`Config`] using a caller-supplied TLS
215    /// connector. Mirrors [`Self::connect_config`] but negotiates TLS instead
216    /// of `postgres::NoTls`.
217    #[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    /// Connect to `url` over TLS using `native-tls` with the system trust
230    /// store (default settings — full certificate chain *and* hostname
231    /// verification against the system trust store, not weakened) — covers
232    /// the common case of a Postgres server with a publicly-trusted
233    /// certificate (e.g. RDS `sslmode=require`). For custom CA bundles or
234    /// client certificates, build a connector and call [`Self::connect_tls`]
235    /// directly.
236    #[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    /// Parse a libpq connstring or `postgresql://` URL into a [`Config`],
244    /// shared by every `connect*(url, ..)` constructor.
245    fn parse_config(url: &str) -> Result<Config> {
246        url.parse()
247            .map_err(|e| KernelError::Store(format!("invalid postgres config: {e}")))
248    }
249
250    /// Shared post-connect setup (schema + migrations) for every constructor.
251    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    /// List up to `limit` nodes (uncapped — unlike `GraphBackend::query_nodes`,
265    /// which is capped at 200). Used by the migration CLI to enumerate a source
266    /// backend of arbitrary size.
267    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    /// List up to `limit` edges (uncapped).
275    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            &params,
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, &params).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, &params).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, &params).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        // FTS match set (ILIKE), used as a binary boost signal.
415        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        // Candidate fetch (broad set), excluding stale nodes.
425        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, &params).map_err(pg_err)?;
443        let candidates: Vec<GraphNode> = rows.iter().map(row_to_node).collect();
444
445        // Composite scoring — identical weights/recency as the SQLite backend.
446        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        // Graph-boost pass: PageRank centrality over the induced subgraph of
468        // the top candidates. Shares the pagerank math with the SQLite recall
469        // path (zero drift) — only the edge-load SQL differs per backend.
470        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        // Touch retrieved nodes in a single statement (access_count++,
530        // accessed_at = now) rather than N round-trips.
531        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, &params);
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                // PostgreSQL requires a single recursive term: the bidirectional
559                // seed is folded into a subquery, then one recursive step follows
560                // edges in either direction (CASE picks the opposite endpoint).
561                "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                &params,
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            &params,
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                &params,
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", &params)
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            &params,
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    /// Bring up a throwaway DB, run `body` against a fresh `PgGraph`, then tear it down.
670    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    /// Offline (no server): the ILIKE pattern transform escapes LIKE wildcards
692    /// and wraps each term — covers `tests-api-1` SQL-input coverage.
693    #[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        // LIKE wildcards (`%`, `_`, `\`) are escaped inside the term.
698        assert_eq!(search_patterns("100%"), vec!["%100\\%%".to_string()]);
699        assert_eq!(search_patterns("a_b"), vec!["%a\\_b%".to_string()]);
700        // Multiple whitespace-separated terms → multiple patterns.
701        assert_eq!(
702            search_patterns("rust db"),
703            vec!["%rust%".to_string(), "%db%".to_string()]
704        );
705    }
706
707    /// `connect_native_tls` against a live PostgreSQL configured for TLS
708    /// (skips without `LLMKERNEL_PG_URL`). If the server does not offer TLS,
709    /// `native-tls` negotiation fails and `connect_native_tls` surfaces that
710    /// as an `Err` rather than panicking — asserting only that shape, since
711    /// most local/CI Postgres instances run without TLS configured.
712    #[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    /// Full `GraphBackend` conformance against a live PostgreSQL (skips without
733    /// `LLMKERNEL_PG_URL`).
734    #[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            // correctness-1: a fresh-id edge with a duplicate (source, target,
799            // relation) triple is IGNORED (ON CONFLICT DO NOTHING catches any
800            // unique violation, matching SQLite's INSERT OR IGNORE) — no hard
801            // unique-violation error on PostgreSQL.
802            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            // correctness-2: a self-loop on the start node is excluded from
818            // related_nodes (PostgreSQL correctly prunes the start node; the
819            // SQLite backend has a pre-existing quirk that leaks it).
820            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    /// SQLite → PostgreSQL migration round-trip through the `GraphBackend` trait
843    /// (skips without `LLMKERNEL_PG_URL`).
844    #[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}