Skip to main content

llm_kernel/graph/
sqlx_pg.rs

1//! Async PostgreSQL graph backend over `sqlx::PgPool` (feature `graph-pg-sqlx`).
2//!
3//! `SqlxPgGraph` mirrors the SQL/DDL/semantics of the synchronous `PgGraph`
4//! (`graph-pg`), but drives every operation asynchronously over a shared
5//! `sqlx::PgPool` instead of one mutex-guarded `postgres::Client`. The SQL is
6//! ported verbatim from `pg.rs` — only the driver changes (sqlx prepared-statement
7//! cache + connection pool instead of blocking `postgres::Client`).
8//!
9//! # Why a second PostgreSQL backend?
10//!
11//! Consumers that already own a `sqlx::PgPool` (notably korean-law-rag's `klr`
12//! citation graph) cannot share a pool or transaction with `PgGraph`, which is
13//! built on a synchronous `postgres::Client`. `SqlxPgGraph` lets such consumers
14//! adopt the llm-kernel graph on their own pool and — critically — **expose the
15//! transaction** so a multi-table prune (chunks + vectors + edges in one tx)
16//! stays atomic. See [`SqlxPgGraph::pool`] and the `*_in_tx` methods.
17//!
18//! This backend is intentionally **non-breaking**: it is a new module that does
19//! not touch `GraphBackend`, `PgGraph`, or any other file. The SQL/schema is
20//! identical to `PgGraph`, so the two backends can share one database (use a
21//! `table_prefix` to namespace tables when both run side by side).
22//!
23//! # Scope
24//!
25//! Inherent async methods cover klr's needs — batch edge writes, directed /
26//! relation-filtered lookups, weighted neighbor aggregation, search, and
27//! traversal — plus basic node/edge CRUD. `query_nodes` and `smart_recall`
28//! (which would require porting the CSR PageRank pass) are out of scope: a
29//! citation graph does not need memory-recall ranking. They are marked with
30//! `TODO(post-1.0)` comments.
31
32use std::collections::{HashMap, HashSet};
33
34use sqlx::postgres::{PgPoolOptions, PgRow};
35use sqlx::{PgPool, Postgres, QueryBuilder, Row};
36
37use super::schema::GRAPH_SCHEMA_VERSION;
38use super::types::{EdgeDirection, GraphEdge, GraphNode, escape_like, join_csv, split_csv};
39use crate::error::{KernelError, Result};
40
41/// Standard node SELECT columns (positional order — keep in sync with [`row_to_node`]).
42const NODE_COLUMNS: &str = "id, node_type, title, tags, projects, agents, \
43     created, updated, body, importance, access_count, accessed_at";
44
45/// Map a `sqlx::Error` into a [`KernelError::Store`].
46fn pg_err(e: sqlx::Error) -> KernelError {
47    KernelError::Store(format!("sqlx: {e:?}"))
48}
49
50/// Map a `nodes` SELECT row into a [`GraphNode`]. Column names match [`NODE_COLUMNS`].
51fn row_to_node(row: &PgRow) -> GraphNode {
52    let tags: String = row.get("tags");
53    let projects: String = row.get("projects");
54    let agents: String = row.get("agents");
55    GraphNode {
56        id: row.get("id"),
57        node_type: row.get("node_type"),
58        title: row.get("title"),
59        tags: split_csv(&tags),
60        projects: split_csv(&projects),
61        agents: split_csv(&agents),
62        created: row.get("created"),
63        updated: row.get("updated"),
64        body: row.get("body"),
65        importance: row.get("importance"),
66        access_count: row.get("access_count"),
67        accessed_at: row.get("accessed_at"),
68    }
69}
70
71/// Map an `edges` SELECT row into a [`GraphEdge`].
72fn row_to_edge(row: &PgRow) -> GraphEdge {
73    GraphEdge {
74        id: row.get("id"),
75        source: row.get("source"),
76        target: row.get("target"),
77        relation: row.get("relation"),
78        weight: row.get("weight"),
79        ts: row.get("ts"),
80    }
81}
82
83/// Build escaped, wrapped ILIKE patterns for each whitespace-separated term in
84/// `query` — e.g. `"rust db"` → `["%rust%", "%db%"]`, `"100%"` → `["%100\\%%"]`.
85/// Pure (no connection); identical to `pg.rs::search_patterns` — duplicated here
86/// because that helper is private and this module must not edit `pg.rs`.
87fn search_patterns(query: &str) -> Vec<String> {
88    query
89        .split_whitespace()
90        .map(|t| format!("%{}%", escape_like(t)))
91        .collect()
92}
93
94/// Validate a PostgreSQL table-name prefix: empty (default, backward-compatible)
95/// or a non-empty run of ASCII alphanumeric / underscore characters. Identical
96/// to `pg.rs::is_identifier_safe` — duplicated here because the `pg` module is
97/// gated behind `graph-pg` (not implied by `graph-pg-sqlx`), and pulling in the
98/// synchronous `postgres`+`clap` deps for one pure function would bloat klr's
99/// async-only dependency tree.
100fn is_identifier_safe(prefix: &str) -> bool {
101    let mut chars = prefix.chars();
102    match chars.next() {
103        None => true,
104        Some(first) if first == '_' || first.is_ascii_alphabetic() => {
105            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
106        }
107        Some(_) => false,
108    }
109}
110
111/// Async PostgreSQL graph backend over a shared `sqlx::PgPool`.
112///
113/// Every constructor applies the schema and runs pending migrations on connect,
114/// matching `PgGraph`. An optional `table_prefix` namespaces every backing table
115/// and index so several graphs can coexist in one database (identical semantics
116/// to `PgGraph`'s `*_with_prefix` constructors). The prefix is validated by
117/// `is_identifier_safe` before any SQL is emitted, keeping the identifier
118/// interpolation injection-safe.
119///
120/// # Transaction exposure
121///
122/// [`pool`](Self::pool) plus [`append_edges_in_tx`](Self::append_edges_in_tx) and
123/// [`remove_edges_for_node_in_tx`](Self::remove_edges_for_node_in_tx) let a caller
124/// coordinate a multi-table transaction (e.g. klr's prune: delete chunks +
125/// vectors + edges atomically). Begin a tx on `pool()`, run cross-table DML,
126/// call the `*_in_tx` helpers with the same `&mut PgConnection`, then commit or
127/// rollback — mirroring `PgVectorIndex::remove_in_tx`.
128pub struct SqlxPgGraph {
129    pool: PgPool,
130    table_prefix: String,
131}
132
133impl SqlxPgGraph {
134    /// Adopt a caller-owned `PgPool`, apply schema + migrations, and return a
135    /// ready backend. Uses the default (empty) table prefix.
136    pub async fn from_pool(pool: PgPool) -> Result<Self> {
137        Self::from_pool_with_prefix(pool, "").await
138    }
139
140    /// Like [`from_pool`](Self::from_pool), but every table/index is namespaced
141    /// under `prefix`. The prefix is validated by `is_identifier_safe` before
142    /// any SQL runs; an unsafe value returns [`KernelError::Store`].
143    pub async fn from_pool_with_prefix(pool: PgPool, prefix: &str) -> Result<Self> {
144        if !is_identifier_safe(prefix) {
145            return Err(KernelError::Store(format!(
146                "invalid table prefix {prefix:?}: only ASCII letters, digits, and underscore are allowed (and the first character must not be a digit)"
147            )));
148        }
149        let graph = Self {
150            pool,
151            table_prefix: prefix.to_string(),
152        };
153        graph.init_schema().await?;
154        graph.migrate().await?;
155        Ok(graph)
156    }
157
158    /// Connect to `url` (libpq connstring / `postgresql://…`) with an 8-connection
159    /// pool, apply schema + migrations, and return a ready backend. Uses the
160    /// default (empty) table prefix.
161    pub async fn connect(url: &str) -> Result<Self> {
162        Self::connect_with_prefix(url, "").await
163    }
164
165    /// Like [`connect`](Self::connect), but every table/index is namespaced
166    /// under `prefix`.
167    pub async fn connect_with_prefix(url: &str, prefix: &str) -> Result<Self> {
168        let pool = PgPoolOptions::new()
169            .max_connections(8)
170            .connect(url)
171            .await
172            .map_err(pg_err)?;
173        Self::from_pool_with_prefix(pool, prefix).await
174    }
175
176    /// The underlying connection pool.
177    ///
178    /// Callers that need cross-table transactional consistency — e.g. pruning a
179    /// law's chunks plus their graph edges atomically — can `pool().begin()` and
180    /// run their own DML, then call the [`append_edges_in_tx`](Self::append_edges_in_tx)
181    /// / [`remove_edges_for_node_in_tx`](Self::remove_edges_for_node_in_tx)
182    /// helpers on the same `&mut PgConnection`.
183    pub fn pool(&self) -> &PgPool {
184        &self.pool
185    }
186
187    // ── table-name helpers (identical to PgGraph) ──────────────────────
188
189    fn nodes_tbl(&self) -> String {
190        format!("{}nodes", self.table_prefix)
191    }
192
193    fn edges_tbl(&self) -> String {
194        format!("{}edges", self.table_prefix)
195    }
196
197    fn meta_tbl(&self) -> String {
198        format!("{}_meta", self.table_prefix)
199    }
200
201    // ── schema init + migrate ──────────────────────────────────────────
202
203    /// Create the graph schema if absent. Idempotent — DDL ported verbatim from
204    /// `pg.rs::init_schema` (same tables, same prefixed index names). Each DDL
205    /// statement is a separate round trip because sqlx's extended-protocol
206    /// `query()` accepts one statement per call; init runs once per connect.
207    async fn init_schema(&self) -> Result<()> {
208        let p = &self.table_prefix;
209        let nodes = self.nodes_tbl();
210        let edges = self.edges_tbl();
211        let meta = self.meta_tbl();
212        let idx = |name: &str| format!("{p}{name}");
213
214        let stmts: Vec<String> = vec![
215            format!(
216                "CREATE TABLE IF NOT EXISTS {nodes} (
217                    id           TEXT PRIMARY KEY,
218                    node_type    TEXT NOT NULL,
219                    title        TEXT NOT NULL,
220                    tags         TEXT NOT NULL DEFAULT '',
221                    projects     TEXT NOT NULL DEFAULT '',
222                    agents       TEXT NOT NULL DEFAULT '',
223                    created      TEXT NOT NULL,
224                    updated      TEXT NOT NULL,
225                    body         TEXT NOT NULL DEFAULT '',
226                    importance   DOUBLE PRECISION NOT NULL DEFAULT 0.5,
227                    access_count BIGINT NOT NULL DEFAULT 0,
228                    accessed_at  TEXT NOT NULL DEFAULT ''
229                )"
230            ),
231            format!(
232                "CREATE TABLE IF NOT EXISTS {edges} (
233                    id       TEXT PRIMARY KEY,
234                    source   TEXT NOT NULL,
235                    target   TEXT NOT NULL,
236                    relation TEXT NOT NULL DEFAULT 'related',
237                    weight   DOUBLE PRECISION NOT NULL DEFAULT 1.0,
238                    ts       TEXT NOT NULL
239                )"
240            ),
241            format!(
242                "CREATE INDEX IF NOT EXISTS {} ON {edges}(source)",
243                idx("idx_edges_source")
244            ),
245            format!(
246                "CREATE INDEX IF NOT EXISTS {} ON {edges}(target)",
247                idx("idx_edges_target")
248            ),
249            format!(
250                "CREATE UNIQUE INDEX IF NOT EXISTS {} ON {edges}(source, target, relation)",
251                idx("idx_edges_src_tgt_rel")
252            ),
253            format!(
254                "CREATE INDEX IF NOT EXISTS {} ON {edges}(source, relation)",
255                idx("idx_edges_src_rel")
256            ),
257            format!(
258                "CREATE INDEX IF NOT EXISTS {} ON {edges}(target, relation)",
259                idx("idx_edges_tgt_rel")
260            ),
261            format!(
262                "CREATE INDEX IF NOT EXISTS {} ON {nodes}(node_type)",
263                idx("idx_nodes_type")
264            ),
265            format!(
266                "CREATE INDEX IF NOT EXISTS {} ON {nodes}(updated DESC)",
267                idx("idx_nodes_updated")
268            ),
269            format!(
270                "CREATE INDEX IF NOT EXISTS {} ON {nodes}(importance DESC)",
271                idx("idx_nodes_importance")
272            ),
273            format!(
274                "CREATE INDEX IF NOT EXISTS {} ON {nodes}(accessed_at DESC)",
275                idx("idx_nodes_accessed")
276            ),
277            format!(
278                "CREATE INDEX IF NOT EXISTS {} ON {nodes}(created)",
279                idx("idx_nodes_created")
280            ),
281            format!(
282                "CREATE TABLE IF NOT EXISTS {meta} (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
283            ),
284            format!(
285                "INSERT INTO {meta} (key, value) VALUES ('graph_schema_version', '{}')
286                 ON CONFLICT (key) DO NOTHING",
287                GRAPH_SCHEMA_VERSION
288            ),
289        ];
290
291        for ddl in &stmts {
292            sqlx::query(ddl).execute(&self.pool).await.map_err(pg_err)?;
293        }
294        Ok(())
295    }
296
297    /// Recorded graph schema version from `{prefix}_meta`, or `0` if unset.
298    pub async fn current_version(&self) -> Result<u32> {
299        let meta = self.meta_tbl();
300        let row = sqlx::query(&format!(
301            "SELECT value FROM {meta} WHERE key = 'graph_schema_version'"
302        ))
303        .fetch_optional(&self.pool)
304        .await
305        .map_err(pg_err)?;
306        match row {
307            Some(r) => {
308                let s: String = r.get("value");
309                Ok(s.parse().unwrap_or(0))
310            }
311            None => Ok(0),
312        }
313    }
314
315    /// Apply pending migrations up to [`GRAPH_SCHEMA_VERSION`]. No-op when current.
316    ///
317    /// Runs in a single transaction with rollback on failure — matching
318    /// `pg.rs::migrate`. Returns the new version.
319    pub async fn migrate(&self) -> Result<u32> {
320        let current = self.current_version().await?;
321        if current >= GRAPH_SCHEMA_VERSION {
322            return Ok(current);
323        }
324        let p = &self.table_prefix;
325        let nodes = self.nodes_tbl();
326        let edges = self.edges_tbl();
327        let meta = self.meta_tbl();
328        let idx = |name: &str| format!("{p}{name}");
329
330        let mut tx = self.pool.begin().await.map_err(pg_err)?;
331        let mut v = current;
332        // v1 -> v2: index nodes by creation timestamp.
333        if v < 2 {
334            sqlx::query(&format!(
335                "CREATE INDEX IF NOT EXISTS {} ON {nodes}(created)",
336                idx("idx_nodes_created")
337            ))
338            .execute(&mut *tx)
339            .await
340            .map_err(pg_err)?;
341            v = 2;
342        }
343        // v2 -> v3: composite indexes for relation-filtered directed edge lookups.
344        if v < 3 {
345            sqlx::query(&format!(
346                "CREATE INDEX IF NOT EXISTS {} ON {edges}(source, relation)",
347                idx("idx_edges_src_rel")
348            ))
349            .execute(&mut *tx)
350            .await
351            .map_err(pg_err)?;
352            sqlx::query(&format!(
353                "CREATE INDEX IF NOT EXISTS {} ON {edges}(target, relation)",
354                idx("idx_edges_tgt_rel")
355            ))
356            .execute(&mut *tx)
357            .await
358            .map_err(pg_err)?;
359            v = 3;
360        }
361        sqlx::query(&format!(
362            "UPDATE {meta} SET value = $1 WHERE key = 'graph_schema_version'"
363        ))
364        .bind(v.to_string())
365        .execute(&mut *tx)
366        .await
367        .map_err(pg_err)?;
368        tx.commit().await.map_err(pg_err)?;
369        Ok(v)
370    }
371
372    // ── basic CRUD ─────────────────────────────────────────────────────
373
374    /// Insert or update a node (upsert by `id`). Mirrors `PgGraph::upsert_node`.
375    pub async fn upsert_node(&self, node: &GraphNode) -> Result<()> {
376        let tags = join_csv(&node.tags);
377        let projects = join_csv(&node.projects);
378        let agents = join_csv(&node.agents);
379        let nodes = self.nodes_tbl();
380        sqlx::query(&format!(
381            "INSERT INTO {nodes} (id, node_type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at)
382             VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
383             ON CONFLICT (id) DO UPDATE SET
384               node_type=EXCLUDED.node_type, title=EXCLUDED.title, tags=EXCLUDED.tags,
385               projects=EXCLUDED.projects, agents=EXCLUDED.agents, created=EXCLUDED.created,
386               updated=EXCLUDED.updated, body=EXCLUDED.body, importance=EXCLUDED.importance,
387               access_count=EXCLUDED.access_count, accessed_at=EXCLUDED.accessed_at"
388        ))
389        .bind(node.id.as_str())
390        .bind(node.node_type.as_str())
391        .bind(node.title.as_str())
392        .bind(tags.as_str())
393        .bind(projects.as_str())
394        .bind(agents.as_str())
395        .bind(node.created.as_str())
396        .bind(node.updated.as_str())
397        .bind(node.body.as_str())
398        .bind(node.importance)
399        .bind(node.access_count)
400        .bind(node.accessed_at.as_str())
401        .execute(&self.pool)
402        .await
403        .map_err(pg_err)?;
404        Ok(())
405    }
406
407    /// Read a node by `id`, or `None` if absent.
408    pub async fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
409        let nodes = self.nodes_tbl();
410        let row = sqlx::query(&format!("SELECT {NODE_COLUMNS} FROM {nodes} WHERE id = $1"))
411            .bind(id)
412            .fetch_optional(&self.pool)
413            .await
414            .map_err(pg_err)?;
415        Ok(row.as_ref().map(row_to_node))
416    }
417
418    /// Delete a node by `id`. Returns `true` if a row was removed.
419    pub async fn delete_node(&self, id: &str) -> Result<bool> {
420        let nodes = self.nodes_tbl();
421        let res = sqlx::query(&format!("DELETE FROM {nodes} WHERE id = $1"))
422            .bind(id)
423            .execute(&self.pool)
424            .await
425            .map_err(pg_err)?;
426        Ok(res.rows_affected() > 0)
427    }
428
429    /// Append a single edge (`ON CONFLICT DO NOTHING`). Mirrors `PgGraph::append_edge`.
430    pub async fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
431        let edges = self.edges_tbl();
432        sqlx::query(&format!(
433            "INSERT INTO {edges} (id, source, target, relation, weight, ts)
434             VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
435        ))
436        .bind(edge.id.as_str())
437        .bind(edge.source.as_str())
438        .bind(edge.target.as_str())
439        .bind(edge.relation.as_str())
440        .bind(edge.weight)
441        .bind(edge.ts.as_str())
442        .execute(&self.pool)
443        .await
444        .map_err(pg_err)?;
445        Ok(())
446    }
447
448    /// All edges touching `node_id` (either endpoint). Mirrors `PgGraph::edges_for_node`.
449    pub async fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
450        let edges = self.edges_tbl();
451        let rows = sqlx::query(&format!(
452            "SELECT id, source, target, relation, weight, ts FROM {edges} \
453             WHERE source = $1 OR target = $1"
454        ))
455        .bind(node_id)
456        .fetch_all(&self.pool)
457        .await
458        .map_err(pg_err)?;
459        Ok(rows.iter().map(row_to_edge).collect())
460    }
461
462    /// Delete a single edge by `id`. Returns `true` if a row was removed.
463    pub async fn delete_edge(&self, id: &str) -> Result<bool> {
464        let edges = self.edges_tbl();
465        let res = sqlx::query(&format!("DELETE FROM {edges} WHERE id = $1"))
466            .bind(id)
467            .execute(&self.pool)
468            .await
469            .map_err(pg_err)?;
470        Ok(res.rows_affected() > 0)
471    }
472
473    // ── klr core: batch edges + directed traversal ─────────────────────
474
475    /// Batch-insert edges in chunks of 5000, each chunk its own transaction
476    /// (`ON CONFLICT DO NOTHING` per row). Mirrors `PgGraph::append_edges`.
477    /// sqlx caches the prepared statement per connection, so the repeated
478    /// `INSERT` reuses one parse/plan per chunk.
479    pub async fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
480        if edges.is_empty() {
481            return Ok(());
482        }
483        let edges_tbl = self.edges_tbl();
484        let sql = format!(
485            "INSERT INTO {edges_tbl} (id, source, target, relation, weight, ts)
486             VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
487        );
488        const CHUNK: usize = 5000;
489        for chunk in edges.chunks(CHUNK) {
490            let mut tx = self.pool.begin().await.map_err(pg_err)?;
491            for e in chunk {
492                sqlx::query(&sql)
493                    .bind(e.id.as_str())
494                    .bind(e.source.as_str())
495                    .bind(e.target.as_str())
496                    .bind(e.relation.as_str())
497                    .bind(e.weight)
498                    .bind(e.ts.as_str())
499                    .execute(&mut *tx)
500                    .await
501                    .map_err(pg_err)?;
502            }
503            tx.commit().await.map_err(pg_err)?;
504        }
505        Ok(())
506    }
507
508    /// Append edges **within a caller-provided transaction** (commit is the
509    /// caller's responsibility — this method does not commit). Enables atomic
510    /// cross-table writes: begin a tx on [`pool`](Self::pool), write chunks /
511    /// vectors / other relations, call this with the same `&mut PgConnection`,
512    /// then `commit()`. A failure anywhere rolls back the whole set.
513    pub async fn append_edges_in_tx(
514        &self,
515        tx: &mut sqlx::PgConnection,
516        edges: &[GraphEdge],
517    ) -> Result<()> {
518        if edges.is_empty() {
519            return Ok(());
520        }
521        let edges_tbl = self.edges_tbl();
522        let sql = format!(
523            "INSERT INTO {edges_tbl} (id, source, target, relation, weight, ts)
524             VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
525        );
526        for e in edges {
527            sqlx::query(&sql)
528                .bind(e.id.as_str())
529                .bind(e.source.as_str())
530                .bind(e.target.as_str())
531                .bind(e.relation.as_str())
532                .bind(e.weight)
533                .bind(e.ts.as_str())
534                .execute(&mut *tx)
535                .await
536                .map_err(pg_err)?;
537        }
538        Ok(())
539    }
540
541    /// Directed, optionally relation-filtered edge lookup. Mirrors
542    /// `PgGraph::edges_for_node_dir`. `dir` selects out / in / both; `relation`
543    /// further restricts to a single relationship type when `Some`.
544    pub async fn edges_for_node_dir(
545        &self,
546        node_id: &str,
547        dir: EdgeDirection,
548        relation: Option<&str>,
549    ) -> Result<Vec<GraphEdge>> {
550        let edges = self.edges_tbl();
551        let dir_clause = match dir {
552            EdgeDirection::Out => "source = $1",
553            EdgeDirection::In => "target = $1",
554            EdgeDirection::Both => "(source = $1 OR target = $1)",
555        };
556        let rows = if let Some(r) = relation {
557            let sql = format!(
558                "SELECT id, source, target, relation, weight, ts FROM {edges} \
559                 WHERE {dir_clause} AND relation = $2 ORDER BY weight DESC"
560            );
561            sqlx::query(&sql)
562                .bind(node_id)
563                .bind(r)
564                .fetch_all(&self.pool)
565                .await
566                .map_err(pg_err)?
567        } else {
568            let sql = format!(
569                "SELECT id, source, target, relation, weight, ts FROM {edges} \
570                 WHERE {dir_clause} ORDER BY weight DESC"
571            );
572            sqlx::query(&sql)
573                .bind(node_id)
574                .fetch_all(&self.pool)
575                .await
576                .map_err(pg_err)?
577        };
578        Ok(rows.iter().map(row_to_edge).collect())
579    }
580
581    /// Aggregate neighbor weights from seed nodes, optionally direction- and
582    /// relation-filtered. Mirrors `PgGraph::neighbors_weighted`: walks the
583    /// source/target halves, uses `= ANY($1)` for the seed set, `GROUP BY` the
584    /// opposite endpoint, and `SUM(weight)`. Seeds themselves are excluded.
585    /// Capped at 100 seeds.
586    pub async fn neighbors_weighted(
587        &self,
588        seed_ids: &[String],
589        dir: EdgeDirection,
590        relation: Option<&str>,
591    ) -> Result<Vec<(String, f64)>> {
592        if seed_ids.is_empty() {
593            return Ok(vec![]);
594        }
595        let edges = self.edges_tbl();
596        const MAX_SEEDS: usize = 100;
597        let seed_ids = if seed_ids.len() > MAX_SEEDS {
598            &seed_ids[..MAX_SEEDS]
599        } else {
600            seed_ids
601        };
602        let seed_arr: Vec<String> = seed_ids.to_vec();
603        let seed_set: HashSet<&str> = seed_ids.iter().map(String::as_str).collect();
604
605        let halves: &[&str] = match dir {
606            EdgeDirection::Out => &["source"],
607            EdgeDirection::In => &["target"],
608            EdgeDirection::Both => &["source", "target"],
609        };
610        let mut weights: HashMap<String, f64> = HashMap::new();
611
612        for &follow in halves {
613            // `follow` is the column the seed matches; the neighbor is the
614            // opposite endpoint.
615            let select_col = if follow == "source" {
616                "target"
617            } else {
618                "source"
619            };
620            let rel_clause = relation.map(|_| " AND relation = $2").unwrap_or("");
621            let sql = format!(
622                "SELECT {select_col} AS nb, SUM(weight) AS w FROM {edges} \
623                 WHERE {follow} = ANY($1){rel_clause} GROUP BY {select_col}"
624            );
625            let rows = if let Some(r) = relation {
626                sqlx::query(&sql)
627                    .bind(&seed_arr)
628                    .bind(r)
629                    .fetch_all(&self.pool)
630                    .await
631                    .map_err(pg_err)?
632            } else {
633                sqlx::query(&sql)
634                    .bind(&seed_arr)
635                    .fetch_all(&self.pool)
636                    .await
637                    .map_err(pg_err)?
638            };
639            for row in &rows {
640                let nb: String = row.get("nb");
641                let w: f64 = row.get("w");
642                if !seed_set.contains(nb.as_str()) {
643                    *weights.entry(nb).or_default() += w;
644                }
645            }
646        }
647
648        let mut result: Vec<(String, f64)> = weights.into_iter().collect();
649        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
650        Ok(result)
651    }
652
653    /// Remove every edge touching `node_id` (either endpoint).
654    /// Mirrors `PgGraph::remove_edges_for_node`.
655    pub async fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
656        let edges = self.edges_tbl();
657        sqlx::query(&format!(
658            "DELETE FROM {edges} WHERE source = $1 OR target = $1"
659        ))
660        .bind(node_id)
661        .execute(&self.pool)
662        .await
663        .map_err(pg_err)?;
664        Ok(())
665    }
666
667    /// Remove edges for `node_id` **within a caller-provided transaction** (no
668    /// commit here). Same atomic-prune pattern as [`Self::append_edges_in_tx`].
669    pub async fn remove_edges_for_node_in_tx(
670        &self,
671        tx: &mut sqlx::PgConnection,
672        node_id: &str,
673    ) -> Result<()> {
674        let edges = self.edges_tbl();
675        sqlx::query(&format!(
676            "DELETE FROM {edges} WHERE source = $1 OR target = $1"
677        ))
678        .bind(node_id)
679        .execute(tx)
680        .await
681        .map_err(pg_err)?;
682        Ok(())
683    }
684
685    // ── search / traversal ─────────────────────────────────────────────
686
687    /// ILIKE substring search over `(title, body, tags)` for each
688    /// whitespace-separated term (AND of terms). Mirrors `PgGraph::search_nodes`
689    /// — extension-free vanilla PostgreSQL ILIKE with escaped, wrapped patterns.
690    pub async fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
691        let terms = search_patterns(query);
692        if terms.is_empty() {
693            return Ok(vec![]);
694        }
695        let nodes = self.nodes_tbl();
696        let mut qb = QueryBuilder::<Postgres>::new("SELECT ");
697        qb.push(NODE_COLUMNS);
698        qb.push(" FROM ");
699        qb.push(nodes.as_str());
700        qb.push(" WHERE ");
701        for (i, term) in terms.iter().enumerate() {
702            if i > 0 {
703                qb.push(" AND ");
704            }
705            qb.push("(title || ' ' || body || ' ' || tags) ILIKE ");
706            qb.push_bind(term.clone());
707            qb.push(" ESCAPE '\\'");
708        }
709        qb.push(" ORDER BY importance DESC, updated DESC LIMIT ");
710        qb.push_bind(limit as i64);
711        let rows = qb.build().fetch_all(&self.pool).await.map_err(pg_err)?;
712        Ok(rows.iter().map(row_to_node).collect())
713    }
714
715    /// Bidirectional BFS up to `depth` hops from `start_id` via a recursive CTE.
716    /// Mirrors `PgGraph::related_nodes`. Returns distinct neighbor IDs (the start
717    /// node is excluded), capped at 500.
718    pub async fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
719        let edges = self.edges_tbl();
720        // PostgreSQL requires a single recursive term: the bidirectional seed
721        // is folded into a subquery, then one recursive step follows edges in
722        // either direction (CASE picks the opposite endpoint).
723        let sql = format!(
724            "WITH RECURSIVE bfs(node_id, lvl) AS (
725                SELECT nb.node_id, 1 FROM (
726                    SELECT target AS node_id FROM {edges} WHERE source = $1
727                    UNION
728                    SELECT source AS node_id FROM {edges} WHERE target = $1
729                ) nb
730                UNION
731                SELECT CASE WHEN e.source = bfs.node_id THEN e.target ELSE e.source END,
732                       bfs.lvl + 1
733                FROM bfs
734                JOIN {edges} e ON e.source = bfs.node_id OR e.target = bfs.node_id
735                WHERE bfs.lvl < $2
736            )
737            SELECT DISTINCT node_id FROM bfs WHERE node_id <> $1 LIMIT 500"
738        );
739        let rows = sqlx::query(&sql)
740            .bind(start_id)
741            .bind(depth as i32)
742            .fetch_all(&self.pool)
743            .await
744            .map_err(pg_err)?;
745        Ok(rows
746            .iter()
747            .map(|r| {
748                let id: String = r.get("node_id");
749                id
750            })
751            .collect())
752    }
753
754    // TODO(post-1.0): query_nodes / smart_recall — klr citation-graph path does
755    // not need them. Porting smart_recall requires the CSR PageRank boost pass
756    // (super::algo::{CsrGraph, pagerank_default}) which is a larger surface.
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    /// `LLMKERNEL_PG_URL` unset → self-skip (pgvector / pg.rs pattern).
764    fn pg_url() -> Option<String> {
765        std::env::var("LLMKERNEL_PG_URL").ok()
766    }
767
768    fn sample_node(id: &str) -> GraphNode {
769        GraphNode {
770            id: id.to_string(),
771            node_type: "concept".to_string(),
772            title: format!("Node {id}"),
773            body: "sqlx pg test body".to_string(),
774            tags: vec!["sqlx".to_string()],
775            projects: vec![],
776            agents: vec![],
777            created: "2026-01-01T00:00:00Z".to_string(),
778            updated: "2026-01-01T00:00:00Z".to_string(),
779            importance: 0.5,
780            access_count: 0,
781            accessed_at: String::new(),
782        }
783    }
784
785    fn sample_edge(id: &str, src: &str, tgt: &str, rel: &str, w: f64) -> GraphEdge {
786        GraphEdge {
787            id: id.to_string(),
788            source: src.to_string(),
789            target: tgt.to_string(),
790            relation: rel.to_string(),
791            weight: w,
792            ts: "2026-01-01T00:00:00Z".to_string(),
793        }
794    }
795
796    /// Drop the prefixed tables so parallel tests with distinct prefixes don't
797    /// accumulate state.
798    async fn cleanup(pool: &PgPool, prefix: &str) {
799        let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {}nodes", prefix))
800            .execute(pool)
801            .await;
802        let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {}edges", prefix))
803            .execute(pool)
804            .await;
805        let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {}_meta", prefix))
806            .execute(pool)
807            .await;
808    }
809
810    // ── offline (no server) ────────────────────────────────────────────
811
812    /// The ILIKE pattern transform is identical to pg.rs (escapes LIKE
813    /// wildcards, wraps each term).
814    #[test]
815    fn search_patterns_escapes_and_wraps() {
816        assert!(search_patterns("").is_empty());
817        assert_eq!(search_patterns("rust"), vec!["%rust%".to_string()]);
818        assert_eq!(search_patterns("100%"), vec!["%100\\%%".to_string()]);
819        assert_eq!(search_patterns("a_b"), vec!["%a\\_b%".to_string()]);
820        assert_eq!(
821            search_patterns("rust db"),
822            vec!["%rust%".to_string(), "%db%".to_string()]
823        );
824    }
825
826    // ── live (LLMKERNEL_PG_URL gated, throwaway prefixed tables) ───────
827
828    /// Basic CRUD round-trip: upsert/read/update/delete nodes + edges.
829    #[tokio::test]
830    async fn basic_crud_roundtrip() {
831        let Some(url) = pg_url() else {
832            eprintln!("skip: LLMKERNEL_PG_URL unset");
833            return;
834        };
835        let prefix = "lk_sg1_";
836        let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
837            .await
838            .expect("connect");
839
840        assert!(g.read_node("n1").await.unwrap().is_none());
841        g.upsert_node(&sample_node("n1")).await.unwrap();
842        let loaded = g.read_node("n1").await.unwrap().unwrap();
843        assert_eq!(loaded.title, "Node n1");
844        assert_eq!(loaded.tags, vec!["sqlx".to_string()]);
845
846        // upsert = update on conflict
847        let mut updated = sample_node("n1");
848        updated.title = "Updated".into();
849        g.upsert_node(&updated).await.unwrap();
850        assert_eq!(g.read_node("n1").await.unwrap().unwrap().title, "Updated");
851
852        // single edge append + edges_for_node + delete_edge
853        g.upsert_node(&sample_node("n2")).await.unwrap();
854        g.append_edge(&sample_edge("e1", "n1", "n2", "related", 1.0))
855            .await
856            .unwrap();
857        assert_eq!(g.edges_for_node("n1").await.unwrap().len(), 1);
858        assert!(g.delete_edge("e1").await.unwrap());
859        assert!(!g.delete_edge("e1").await.unwrap());
860        assert_eq!(g.edges_for_node("n1").await.unwrap().len(), 0);
861
862        // delete node
863        assert!(g.delete_node("n1").await.unwrap());
864        assert!(!g.delete_node("n1").await.unwrap());
865        assert!(g.read_node("n1").await.unwrap().is_none());
866
867        cleanup(g.pool(), prefix).await;
868    }
869
870    /// Batch edges with `ON CONFLICT DO NOTHING` dedup: a fresh-id edge sharing
871    /// a duplicate (source, target, relation) triple is silently ignored.
872    #[tokio::test]
873    async fn batch_edges_dedup() {
874        let Some(url) = pg_url() else {
875            eprintln!("skip: LLMKERNEL_PG_URL unset");
876            return;
877        };
878        let prefix = "lk_sg2_";
879        let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
880            .await
881            .expect("connect");
882
883        for n in &["a", "b", "c"] {
884            g.upsert_node(&sample_node(n)).await.unwrap();
885        }
886
887        let edges = vec![
888            sample_edge("e1", "a", "b", "cites", 1.0),
889            sample_edge("e2", "a", "c", "cites", 0.8),
890            // duplicate (src,tgt,rel) of e1 — ON CONFLICT DO NOTHING
891            sample_edge("e1dup", "a", "b", "cites", 2.0),
892        ];
893        g.append_edges(&edges).await.unwrap();
894
895        // Only e1 and e2 survived (e1dup deduped by the unique index).
896        let out = g
897            .edges_for_node_dir("a", EdgeDirection::Out, Some("cites"))
898            .await
899            .unwrap();
900        assert_eq!(out.len(), 2, "duplicate (src,tgt,rel) edge ignored");
901
902        // empty slice is a no-op
903        g.append_edges(&[]).await.unwrap();
904
905        cleanup(g.pool(), prefix).await;
906    }
907
908    /// Directed, relation-filtered edge lookup + weighted neighbor aggregation.
909    #[tokio::test]
910    async fn edges_dir_and_neighbors() {
911        let Some(url) = pg_url() else {
912            eprintln!("skip: LLMKERNEL_PG_URL unset");
913            return;
914        };
915        let prefix = "lk_sg3_";
916        let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
917            .await
918            .expect("connect");
919
920        for n in &["seed", "t1", "t2", "t3"] {
921            g.upsert_node(&sample_node(n)).await.unwrap();
922        }
923
924        // seed cites t1/t2/t3 (out), t1 cites seed (in), seed related t1 (other rel)
925        g.append_edges(&[
926            sample_edge("e1", "seed", "t1", "cites", 1.0),
927            sample_edge("e2", "seed", "t2", "cites", 0.5),
928            sample_edge("e3", "seed", "t3", "cites", 0.3),
929        ])
930        .await
931        .unwrap();
932        g.append_edge(&sample_edge("e4", "t1", "seed", "cites", 2.0))
933            .await
934            .unwrap();
935        g.append_edge(&sample_edge("e5", "seed", "t1", "related", 1.0))
936            .await
937            .unwrap();
938
939        // Out + "cites": 3 (t1, t2, t3)
940        let out_cites = g
941            .edges_for_node_dir("seed", EdgeDirection::Out, Some("cites"))
942            .await
943            .unwrap();
944        assert_eq!(out_cites.len(), 3);
945
946        // In + "cites": 1 (from t1)
947        let in_cites = g
948            .edges_for_node_dir("seed", EdgeDirection::In, Some("cites"))
949            .await
950            .unwrap();
951        assert_eq!(in_cites.len(), 1);
952        assert_eq!(in_cites[0].source, "t1");
953
954        // neighbors_weighted Out "cites" → t1(1.0), t2(0.5), t3(0.3), desc by weight
955        let neighbors = g
956            .neighbors_weighted(&["seed".to_string()], EdgeDirection::Out, Some("cites"))
957            .await
958            .unwrap();
959        assert_eq!(neighbors.len(), 3);
960        assert_eq!(neighbors[0].0, "t1");
961        assert!((neighbors[0].1 - 1.0).abs() < 1e-9);
962        assert_eq!(neighbors[2].0, "t3");
963
964        cleanup(g.pool(), prefix).await;
965    }
966
967    /// `remove_edges_for_node` drops only the edges touching the target node.
968    #[tokio::test]
969    async fn remove_edges_for_node_drops_only_touching() {
970        let Some(url) = pg_url() else {
971            eprintln!("skip: LLMKERNEL_PG_URL unset");
972            return;
973        };
974        let prefix = "lk_sg4_";
975        let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
976            .await
977            .expect("connect");
978
979        for n in &["x", "y", "z"] {
980            g.upsert_node(&sample_node(n)).await.unwrap();
981        }
982        g.append_edges(&[
983            sample_edge("e1", "x", "y", "cites", 1.0),
984            sample_edge("e2", "z", "x", "cites", 1.0),
985            sample_edge("e3", "y", "z", "cites", 1.0),
986        ])
987        .await
988        .unwrap();
989        assert_eq!(g.edges_for_node("x").await.unwrap().len(), 2);
990
991        g.remove_edges_for_node("x").await.unwrap();
992        assert_eq!(g.edges_for_node("x").await.unwrap().len(), 0);
993        // y-z edge survives (neither endpoint is x)
994        assert_eq!(g.edges_for_node("y").await.unwrap().len(), 1);
995
996        cleanup(g.pool(), prefix).await;
997    }
998
999    /// Transaction atomicity: `*_in_tx` commit and rollback paths.
1000    /// This is the core guarantee klr relies on for atomic prune.
1001    #[tokio::test]
1002    async fn tx_atomicity_commit_and_rollback() {
1003        let Some(url) = pg_url() else {
1004            eprintln!("skip: LLMKERNEL_PG_URL unset");
1005            return;
1006        };
1007        let prefix = "lk_sg5_";
1008        let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
1009            .await
1010            .expect("connect");
1011
1012        for n in &["p", "q"] {
1013            g.upsert_node(&sample_node(n)).await.unwrap();
1014        }
1015
1016        // Commit path: append_edges_in_tx → commit → edge visible
1017        let mut tx = g.pool().begin().await.expect("begin tx");
1018        g.append_edges_in_tx(&mut tx, &[sample_edge("e1", "p", "q", "cites", 1.0)])
1019            .await
1020            .unwrap();
1021        tx.commit().await.expect("commit");
1022        assert_eq!(g.edges_for_node("p").await.unwrap().len(), 1);
1023
1024        // Rollback path: append_edges_in_tx → rollback → edge NOT visible
1025        let mut tx2 = g.pool().begin().await.expect("begin tx2");
1026        g.append_edges_in_tx(&mut tx2, &[sample_edge("e2", "p", "q", "cites", 1.0)])
1027            .await
1028            .unwrap();
1029        tx2.rollback().await.expect("rollback");
1030        assert_eq!(
1031            g.edges_for_node("p").await.unwrap().len(),
1032            1,
1033            "rolled-back edge not visible"
1034        );
1035
1036        // remove_edges_for_node_in_tx rollback → edge survives
1037        let mut tx3 = g.pool().begin().await.expect("begin tx3");
1038        g.remove_edges_for_node_in_tx(&mut tx3, "p").await.unwrap();
1039        tx3.rollback().await.expect("rollback tx3");
1040        assert_eq!(g.edges_for_node("p").await.unwrap().len(), 1);
1041
1042        // remove_edges_for_node_in_tx commit → edge removed
1043        let mut tx4 = g.pool().begin().await.expect("begin tx4");
1044        g.remove_edges_for_node_in_tx(&mut tx4, "p").await.unwrap();
1045        tx4.commit().await.expect("commit tx4");
1046        assert_eq!(g.edges_for_node("p").await.unwrap().len(), 0);
1047
1048        cleanup(g.pool(), prefix).await;
1049    }
1050
1051    /// ILIKE search + recursive BFS traversal + version helpers.
1052    #[tokio::test]
1053    async fn search_related_and_version() {
1054        let Some(url) = pg_url() else {
1055            eprintln!("skip: LLMKERNEL_PG_URL unset");
1056            return;
1057        };
1058        let prefix = "lk_sg6_";
1059        let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
1060            .await
1061            .expect("connect");
1062
1063        let mut rust = sample_node("rust");
1064        rust.title = "Rust ownership".into();
1065        rust.body = "borrow checker".into();
1066        g.upsert_node(&rust).await.unwrap();
1067        g.upsert_node(&sample_node("py")).await.unwrap();
1068
1069        // search_nodes
1070        let hits = g.search_nodes("rust", 10).await.unwrap();
1071        assert_eq!(hits.len(), 1);
1072        assert_eq!(hits[0].id, "rust");
1073        assert!(g.search_nodes("", 10).await.unwrap().is_empty());
1074
1075        // related_nodes via BFS
1076        g.append_edge(&sample_edge("e1", "rust", "py", "related", 1.0))
1077            .await
1078            .unwrap();
1079        let related = g.related_nodes("rust", 2).await.unwrap();
1080        assert!(related.contains(&"py".to_string()));
1081
1082        // version helpers (schema applied on connect)
1083        assert_eq!(g.current_version().await.unwrap(), GRAPH_SCHEMA_VERSION);
1084        assert_eq!(g.migrate().await.unwrap(), GRAPH_SCHEMA_VERSION);
1085
1086        cleanup(g.pool(), prefix).await;
1087    }
1088
1089    /// An invalid table prefix is rejected at construction (before any SQL).
1090    #[tokio::test]
1091    async fn invalid_prefix_rejected() {
1092        let Some(url) = pg_url() else {
1093            eprintln!("skip: LLMKERNEL_PG_URL unset");
1094            return;
1095        };
1096        assert!(
1097            SqlxPgGraph::connect_with_prefix(&url, "lk; drop")
1098                .await
1099                .is_err()
1100        );
1101        assert!(SqlxPgGraph::connect_with_prefix(&url, "1lk").await.is_err());
1102    }
1103}