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//! (With a non-empty [`PgGraph`] table prefix, substitute the prefixed table
21//! name in that out-of-band DDL.)
22//!
23//! # TLS (`graph-pg-tls` feature)
24//!
25//! [`PgGraph::connect`] / [`PgGraph::connect_config`] always use
26//! `postgres::NoTls` — servers requiring `sslmode=require` or stricter reject
27//! that handshake. Enabling `graph-pg-tls` adds [`PgGraph::connect_native_tls`]
28//! (system trust store, one call) plus [`PgGraph::connect_tls`] /
29//! [`PgGraph::connect_config_tls`] for a caller-supplied
30//! `postgres::tls::MakeTlsConnect` implementor.
31//!
32//! # Table prefix
33//!
34//! Every constructor has a `*_with_prefix` variant that namespaces the backing
35//! `nodes` / `edges` / `_meta` tables — and every index name — under a
36//! caller-chosen prefix, so several graphs (or a graph and unrelated service
37//! tables) can coexist in one database. The default empty prefix preserves the
38//! original `nodes` / `edges` / `_meta` names exactly: existing databases and
39//! tests are unaffected. Because the prefix is interpolated into DDL/DML as a
40//! bare identifier (PostgreSQL does not accept bind parameters for
41//! identifiers), it is validated by `is_identifier_safe` before any SQL is
42//! emitted, keeping the interpolation injection-safe.
43
44use std::collections::HashSet;
45use std::sync::{Mutex, MutexGuard};
46use std::time::{SystemTime, UNIX_EPOCH};
47
48use postgres::row::Row;
49use postgres::types::ToSql;
50use postgres::{Client, Config, NoTls};
51
52use super::algo::{CsrGraph, pagerank_default};
53use super::lifecycle::now_iso;
54use super::recall::{W_ACCESS, W_FTS, W_GRAPH, W_IMPORTANCE, W_RECENCY, compute_recency};
55use super::schema::GRAPH_SCHEMA_VERSION;
56use super::types::{EdgeDirection, escape_like, join_csv, split_csv};
57use super::{GraphBackend, GraphEdge, GraphNode, ScoredNode};
58use crate::error::{KernelError, Result};
59
60/// Standard node SELECT columns (positional order — keep in sync with [`row_to_node`]).
61const NODE_COLUMNS: &str = "id, node_type, title, tags, projects, agents, \
62     created, updated, body, importance, access_count, accessed_at";
63
64/// Map a `postgres` error into a [`KernelError::Store`].
65fn pg_err(e: postgres::Error) -> KernelError {
66    KernelError::Store(format!("postgres: {e:?}"))
67}
68
69/// Map a `nodes` SELECT row into a [`GraphNode`]. Column order matches [`NODE_COLUMNS`].
70fn row_to_node(row: &Row) -> GraphNode {
71    GraphNode {
72        id: row.get(0),
73        node_type: row.get(1),
74        title: row.get(2),
75        tags: split_csv(&row.get::<_, String>(3)),
76        projects: split_csv(&row.get::<_, String>(4)),
77        agents: split_csv(&row.get::<_, String>(5)),
78        created: row.get(6),
79        updated: row.get(7),
80        body: row.get(8),
81        importance: row.get(9),
82        access_count: row.get(10),
83        accessed_at: row.get(11),
84    }
85}
86
87/// Map an `edges` SELECT row into a [`GraphEdge`].
88///
89/// Column order: `id, source, target, relation, weight, ts`.
90fn row_to_edge(row: &Row) -> GraphEdge {
91    GraphEdge {
92        id: row.get(0),
93        source: row.get(1),
94        target: row.get(2),
95        relation: row.get(3),
96        weight: row.get(4),
97        ts: row.get(5),
98    }
99}
100
101/// Build escaped, wrapped ILIKE patterns for each whitespace-separated term in
102/// `query` — e.g. `"rust db"` → `["%rust%", "%db%"]`, `"100%"` → `["%100\\%%"]`.
103/// Pure (no connection) so the SQL-input transform is unit-testable offline.
104fn search_patterns(query: &str) -> Vec<String> {
105    query
106        .split_whitespace()
107        .map(|t| format!("%{}%", escape_like(t)))
108        .collect()
109}
110
111/// Validate a PostgreSQL table-name prefix: empty (default, backward-compatible)
112/// or a non-empty run of ASCII alphanumeric / underscore characters. The prefix
113/// is interpolated into DDL/DML as a bare identifier (identifiers cannot be
114/// passed as bind parameters), so rejecting anything outside this charset is
115/// what keeps the interpolation injection-safe. A digit-leading prefix is also
116/// rejected because a PostgreSQL identifier may not start with a digit — this
117/// guards the combined `{prefix}nodes` / `{prefix}_meta` names against forming
118/// an invalid unquoted identifier at runtime.
119fn is_identifier_safe(prefix: &str) -> bool {
120    let mut chars = prefix.chars();
121    match chars.next() {
122        None => true,
123        Some(first) if first == '_' || first.is_ascii_alphabetic() => {
124            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
125        }
126        Some(_) => false,
127    }
128}
129
130/// Create the graph schema if absent. Idempotent — safe on every connect.
131/// `prefix` namespaces every table/index name (empty = original names).
132fn init_schema(client: &mut Client, prefix: &str) -> Result<()> {
133    let nodes = format!("{prefix}nodes");
134    let edges = format!("{prefix}edges");
135    let meta = format!("{prefix}_meta");
136    let idx_edges_source = format!("{prefix}idx_edges_source");
137    let idx_edges_target = format!("{prefix}idx_edges_target");
138    let idx_edges_src_tgt_rel = format!("{prefix}idx_edges_src_tgt_rel");
139    let idx_edges_src_rel = format!("{prefix}idx_edges_src_rel");
140    let idx_edges_tgt_rel = format!("{prefix}idx_edges_tgt_rel");
141    let idx_nodes_type = format!("{prefix}idx_nodes_type");
142    let idx_nodes_updated = format!("{prefix}idx_nodes_updated");
143    let idx_nodes_importance = format!("{prefix}idx_nodes_importance");
144    let idx_nodes_accessed = format!("{prefix}idx_nodes_accessed");
145    let idx_nodes_created = format!("{prefix}idx_nodes_created");
146    client
147        .batch_execute(&format!(
148            "CREATE TABLE IF NOT EXISTS {nodes} (
149                id           TEXT PRIMARY KEY,
150                node_type    TEXT NOT NULL,
151                title        TEXT NOT NULL,
152                tags         TEXT NOT NULL DEFAULT '',
153                projects     TEXT NOT NULL DEFAULT '',
154                agents       TEXT NOT NULL DEFAULT '',
155                created      TEXT NOT NULL,
156                updated      TEXT NOT NULL,
157                body         TEXT NOT NULL DEFAULT '',
158                importance   DOUBLE PRECISION NOT NULL DEFAULT 0.5,
159                access_count BIGINT NOT NULL DEFAULT 0,
160                accessed_at  TEXT NOT NULL DEFAULT ''
161            );
162            CREATE TABLE IF NOT EXISTS {edges} (
163                id       TEXT PRIMARY KEY,
164                source   TEXT NOT NULL,
165                target   TEXT NOT NULL,
166                relation TEXT NOT NULL DEFAULT 'related',
167                weight   DOUBLE PRECISION NOT NULL DEFAULT 1.0,
168                ts       TEXT NOT NULL
169            );
170            CREATE INDEX IF NOT EXISTS {idx_edges_source}  ON {edges}(source);
171            CREATE INDEX IF NOT EXISTS {idx_edges_target}  ON {edges}(target);
172            CREATE UNIQUE INDEX IF NOT EXISTS {idx_edges_src_tgt_rel} ON {edges}(source, target, relation);
173            CREATE INDEX IF NOT EXISTS {idx_edges_src_rel} ON {edges}(source, relation);
174            CREATE INDEX IF NOT EXISTS {idx_edges_tgt_rel} ON {edges}(target, relation);
175            CREATE INDEX IF NOT EXISTS {idx_nodes_type}       ON {nodes}(node_type);
176            CREATE INDEX IF NOT EXISTS {idx_nodes_updated}    ON {nodes}(updated DESC);
177            CREATE INDEX IF NOT EXISTS {idx_nodes_importance} ON {nodes}(importance DESC);
178            CREATE INDEX IF NOT EXISTS {idx_nodes_accessed}   ON {nodes}(accessed_at DESC);
179            CREATE INDEX IF NOT EXISTS {idx_nodes_created}    ON {nodes}(created);
180            CREATE TABLE IF NOT EXISTS {meta} (key TEXT PRIMARY KEY, value TEXT NOT NULL);
181            INSERT INTO {meta} (key, value) VALUES ('graph_schema_version', '3')
182                ON CONFLICT (key) DO NOTHING;",
183        ))
184        .map_err(pg_err)?;
185    Ok(())
186}
187
188/// Recorded graph schema version from `{prefix}_meta`, or `0` if unset.
189fn schema_version(client: &mut Client, prefix: &str) -> Result<u32> {
190    let meta = format!("{prefix}_meta");
191    let row = client
192        .query_opt(
193            &format!("SELECT value FROM {meta} WHERE key = 'graph_schema_version'"),
194            &[],
195        )
196        .map_err(pg_err)?;
197    Ok(row
198        .map(|r| r.get::<_, String>(0))
199        .and_then(|s| s.parse().ok())
200        .unwrap_or(0))
201}
202
203/// Apply pending migrations up to [`GRAPH_SCHEMA_VERSION`]. No-op when current.
204///
205/// Runs in a single transaction with rollback on failure — matching the SQLite
206/// `migrate_graph` semantics. `prefix` namespaces every table/index name.
207fn migrate(client: &mut Client, current: u32, prefix: &str) -> Result<u32> {
208    if current >= GRAPH_SCHEMA_VERSION {
209        return Ok(current);
210    }
211    let nodes = format!("{prefix}nodes");
212    let edges = format!("{prefix}edges");
213    let meta = format!("{prefix}_meta");
214    let idx_nodes_created = format!("{prefix}idx_nodes_created");
215    let idx_edges_src_rel = format!("{prefix}idx_edges_src_rel");
216    let idx_edges_tgt_rel = format!("{prefix}idx_edges_tgt_rel");
217    let mut tx = client.transaction().map_err(pg_err)?;
218    let mut v = current;
219    if v < 2 {
220        tx.batch_execute(&format!(
221            "CREATE INDEX IF NOT EXISTS {idx_nodes_created} ON {nodes}(created);"
222        ))
223        .map_err(pg_err)?;
224        v = 2;
225    }
226    // v2 -> v3: composite indexes for relation-filtered directed edge lookups.
227    if v < 3 {
228        tx.batch_execute(&format!(
229            "CREATE INDEX IF NOT EXISTS {idx_edges_src_rel} ON {edges}(source, relation);
230             CREATE INDEX IF NOT EXISTS {idx_edges_tgt_rel} ON {edges}(target, relation);",
231        ))
232        .map_err(pg_err)?;
233        v = 3;
234    }
235    tx.execute(
236        &format!("UPDATE {meta} SET value = $1 WHERE key = 'graph_schema_version'"),
237        &[&v.to_string()],
238    )
239    .map_err(pg_err)?;
240    tx.commit().map_err(pg_err)?;
241    Ok(v)
242}
243
244/// PostgreSQL-backed `GraphBackend` over one mutex-guarded connection.
245///
246/// Opening applies the schema and runs pending migrations, matching
247/// `SqliteGraph::open` in the main crate. An optional `table_prefix`
248/// (empty by default) namespaces every backing table and index so multiple
249/// graphs can share one database — see the `*_with_prefix` constructors.
250pub struct PgGraph {
251    client: Mutex<Client>,
252    table_prefix: String,
253}
254
255impl PgGraph {
256    /// Connect to `url` (libpq connstring or `postgresql://` URL), apply schema
257    /// and migrations, and return a ready backend. Uses the default (empty)
258    /// table prefix.
259    pub fn connect(url: &str) -> Result<Self> {
260        Self::connect_config(&Self::parse_config(url)?)
261    }
262
263    /// Like [`connect`](Self::connect), but every table/index is namespaced
264    /// under `prefix` (e.g. `"lk_"` → `lk_nodes` / `lk_edges` / `lk_meta`).
265    /// The prefix is validated by `is_identifier_safe` before any SQL runs.
266    pub fn connect_with_prefix(url: &str, prefix: &str) -> Result<Self> {
267        Self::connect_config_with_prefix(&Self::parse_config(url)?, prefix)
268    }
269
270    /// Connect from a pre-built [`Config`] (useful for overriding `dbname`,
271    /// e.g. when targeting a throwaway test database). Uses the default
272    /// (empty) table prefix.
273    pub fn connect_config(config: &Config) -> Result<Self> {
274        let client = config.connect(NoTls).map_err(pg_err)?;
275        Self::from_client(client)
276    }
277
278    /// Like [`connect_config`](Self::connect_config), but every table/index is
279    /// namespaced under `prefix`.
280    pub fn connect_config_with_prefix(config: &Config, prefix: &str) -> Result<Self> {
281        let client = config.connect(NoTls).map_err(pg_err)?;
282        Self::from_client_with_prefix(client, prefix)
283    }
284
285    /// Connect to `url` using a caller-supplied TLS connector — for servers
286    /// requiring `sslmode=require` or stricter (e.g. RDS with
287    /// `rds.force_ssl`). See [`Self::connect_native_tls`] for the common case
288    /// of a system-trust-store `native-tls` connector. Uses the default
289    /// (empty) table prefix.
290    #[cfg(feature = "graph-pg-tls")]
291    pub fn connect_tls<T>(url: &str, connector: T) -> Result<Self>
292    where
293        T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
294        T::TlsConnect: Send,
295        T::Stream: Send,
296        <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
297    {
298        Self::connect_config_tls(&Self::parse_config(url)?, connector)
299    }
300
301    /// TLS variant of [`connect_with_prefix`](Self::connect_with_prefix) with a
302    /// caller-supplied TLS connector.
303    #[cfg(feature = "graph-pg-tls")]
304    pub fn connect_tls_with_prefix<T>(url: &str, prefix: &str, connector: T) -> Result<Self>
305    where
306        T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
307        T::TlsConnect: Send,
308        T::Stream: Send,
309        <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
310    {
311        Self::connect_config_tls_with_prefix(&Self::parse_config(url)?, prefix, connector)
312    }
313
314    /// Connect from a pre-built [`Config`] using a caller-supplied TLS
315    /// connector. Mirrors [`Self::connect_config`] but negotiates TLS instead
316    /// of `postgres::NoTls`. Uses the default (empty) table prefix.
317    #[cfg(feature = "graph-pg-tls")]
318    pub fn connect_config_tls<T>(config: &Config, connector: T) -> Result<Self>
319    where
320        T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
321        T::TlsConnect: Send,
322        T::Stream: Send,
323        <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
324    {
325        let client = config.connect(connector).map_err(pg_err)?;
326        Self::from_client(client)
327    }
328
329    /// TLS variant of [`connect_config_with_prefix`](Self::connect_config_with_prefix)
330    /// with a caller-supplied TLS connector.
331    #[cfg(feature = "graph-pg-tls")]
332    pub fn connect_config_tls_with_prefix<T>(
333        config: &Config,
334        prefix: &str,
335        connector: T,
336    ) -> Result<Self>
337    where
338        T: postgres::tls::MakeTlsConnect<postgres::Socket> + Send + 'static,
339        T::TlsConnect: Send,
340        T::Stream: Send,
341        <T::TlsConnect as postgres::tls::TlsConnect<postgres::Socket>>::Future: Send,
342    {
343        let client = config.connect(connector).map_err(pg_err)?;
344        Self::from_client_with_prefix(client, prefix)
345    }
346
347    /// Connect to `url` over TLS using `native-tls` with the system trust
348    /// store (default settings — full certificate chain *and* hostname
349    /// verification against the system trust store, not weakened) — covers
350    /// the common case of a Postgres server with a publicly-trusted
351    /// certificate (e.g. RDS `sslmode=require`). For custom CA bundles or
352    /// client certificates, build a connector and call [`Self::connect_tls`]
353    /// directly. Uses the default (empty) table prefix.
354    #[cfg(feature = "graph-pg-tls")]
355    pub fn connect_native_tls(url: &str) -> Result<Self> {
356        let tls = native_tls::TlsConnector::new()
357            .map_err(|e| KernelError::Store(format!("native-tls connector: {e}")))?;
358        Self::connect_tls(url, postgres_native_tls::MakeTlsConnector::new(tls))
359    }
360
361    /// TLS variant of [`connect_with_prefix`](Self::connect_with_prefix) using
362    /// `native-tls` with the system trust store.
363    #[cfg(feature = "graph-pg-tls")]
364    pub fn connect_native_tls_with_prefix(url: &str, prefix: &str) -> Result<Self> {
365        let tls = native_tls::TlsConnector::new()
366            .map_err(|e| KernelError::Store(format!("native-tls connector: {e}")))?;
367        Self::connect_tls_with_prefix(url, prefix, postgres_native_tls::MakeTlsConnector::new(tls))
368    }
369
370    /// Parse a libpq connstring or `postgresql://` URL into a [`Config`],
371    /// shared by every `connect*(url, ..)` constructor.
372    fn parse_config(url: &str) -> Result<Config> {
373        url.parse()
374            .map_err(|e| KernelError::Store(format!("invalid postgres config: {e}")))
375    }
376
377    /// Shared post-connect setup (schema + migrations) for every constructor.
378    ///
379    /// Public so a consumer that already owns a synchronous `postgres::Client`
380    /// can adopt `PgGraph` without re-opening the connection. For an *async*
381    /// pool (e.g. `sqlx::PgPool`), use the planned `SqlxPgGraph` backend instead.
382    /// Uses the default (empty) table prefix.
383    pub fn from_client(client: Client) -> Result<Self> {
384        Self::from_client_with_prefix(client, "")
385    }
386
387    /// Like [`from_client`](Self::from_client), but every table/index is
388    /// namespaced under `prefix`. The prefix is validated first; an unsafe
389    /// value (anything beyond ASCII alphanumeric / underscore, or a digit-led
390    /// name) returns [`KernelError::Store`] before any SQL is emitted.
391    pub fn from_client_with_prefix(mut client: Client, prefix: &str) -> Result<Self> {
392        if !is_identifier_safe(prefix) {
393            return Err(KernelError::Store(format!(
394                "invalid table prefix {prefix:?}: only ASCII letters, digits, and underscore are allowed (and the first character must not be a digit)"
395            )));
396        }
397        init_schema(&mut client, prefix)?;
398        let current = schema_version(&mut client, prefix)?;
399        migrate(&mut client, current, prefix)?;
400        Ok(Self {
401            client: Mutex::new(client),
402            table_prefix: prefix.to_string(),
403        })
404    }
405
406    fn lock(&self) -> MutexGuard<'_, Client> {
407        self.client.lock().unwrap_or_else(|e| e.into_inner())
408    }
409
410    /// Fully-qualified `nodes` table name for this backend's prefix.
411    fn nodes_tbl(&self) -> String {
412        format!("{}nodes", self.table_prefix)
413    }
414
415    /// Fully-qualified `edges` table name for this backend's prefix.
416    fn edges_tbl(&self) -> String {
417        format!("{}edges", self.table_prefix)
418    }
419
420    /// Fully-qualified `_meta` table name for this backend's prefix.
421    fn meta_tbl(&self) -> String {
422        format!("{}_meta", self.table_prefix)
423    }
424
425    /// List up to `limit` nodes (uncapped — unlike `GraphBackend::query_nodes`,
426    /// which is capped at 200). Used by the migration CLI to enumerate a source
427    /// backend of arbitrary size.
428    pub fn list_nodes(&self, limit: usize) -> Result<Vec<GraphNode>> {
429        let nodes = self.nodes_tbl();
430        let mut c = self.lock();
431        let sql = format!("SELECT {NODE_COLUMNS} FROM {nodes} ORDER BY updated DESC LIMIT {limit}");
432        let rows = c.query(&sql, &[]).map_err(pg_err)?;
433        Ok(rows.iter().map(row_to_node).collect())
434    }
435
436    /// List up to `limit` edges (uncapped).
437    pub fn list_edges(&self, limit: usize) -> Result<Vec<GraphEdge>> {
438        let edges = self.edges_tbl();
439        let mut c = self.lock();
440        let rows = c
441            .query(
442                &format!("SELECT id, source, target, relation, weight, ts FROM {edges} LIMIT $1"),
443                &[&(limit as i64)],
444            )
445            .map_err(pg_err)?;
446        Ok(rows.iter().map(row_to_edge).collect())
447    }
448}
449
450impl GraphBackend for PgGraph {
451    fn upsert_node(&self, node: &GraphNode) -> Result<()> {
452        let tags = join_csv(&node.tags);
453        let projects = join_csv(&node.projects);
454        let agents = join_csv(&node.agents);
455        let params: [&(dyn ToSql + Sync); 12] = [
456            &node.id,
457            &node.node_type,
458            &node.title,
459            &tags,
460            &projects,
461            &agents,
462            &node.created,
463            &node.updated,
464            &node.body,
465            &node.importance,
466            &node.access_count,
467            &node.accessed_at,
468        ];
469        let nodes = self.nodes_tbl();
470        let mut c = self.lock();
471        c.execute(
472            &format!(
473                "INSERT INTO {nodes} (id, node_type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at)
474             VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
475             ON CONFLICT (id) DO UPDATE SET
476               node_type=EXCLUDED.node_type, title=EXCLUDED.title, tags=EXCLUDED.tags,
477               projects=EXCLUDED.projects, agents=EXCLUDED.agents, created=EXCLUDED.created,
478               updated=EXCLUDED.updated, body=EXCLUDED.body, importance=EXCLUDED.importance,
479               access_count=EXCLUDED.access_count, accessed_at=EXCLUDED.accessed_at"
480            ),
481            &params,
482        )
483        .map_err(pg_err)?;
484        Ok(())
485    }
486
487    fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
488        let nodes = self.nodes_tbl();
489        let mut c = self.lock();
490        let sql = format!("SELECT {NODE_COLUMNS} FROM {nodes} WHERE id = $1");
491        let params: [&(dyn ToSql + Sync); 1] = [&id];
492        let row = c.query_opt(&sql, &params).map_err(pg_err)?;
493        Ok(row.as_ref().map(row_to_node))
494    }
495
496    fn delete_node(&self, id: &str) -> Result<bool> {
497        let nodes = self.nodes_tbl();
498        let mut c = self.lock();
499        let n = c
500            .execute(&format!("DELETE FROM {nodes} WHERE id = $1"), &[&id])
501            .map_err(pg_err)?;
502        Ok(n > 0)
503    }
504
505    fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
506        let terms = search_patterns(query);
507        if terms.is_empty() {
508            return Ok(vec![]);
509        }
510        let mut conds: Vec<String> = Vec::with_capacity(terms.len());
511        let params: Vec<&(dyn ToSql + Sync)> =
512            terms.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
513        for i in 0..terms.len() {
514            conds.push(format!(
515                "(title || ' ' || body || ' ' || tags) ILIKE ${n} ESCAPE '\\'",
516                n = i + 1
517            ));
518        }
519        let where_clause = conds.join(" AND ");
520        let nodes = self.nodes_tbl();
521        let sql = format!(
522            "SELECT {NODE_COLUMNS} FROM {nodes} WHERE {where_clause} ORDER BY importance DESC, updated DESC LIMIT {limit}"
523        );
524        let mut c = self.lock();
525        let rows = c.query(&sql, &params).map_err(pg_err)?;
526        Ok(rows.iter().map(row_to_node).collect())
527    }
528
529    fn query_nodes(
530        &self,
531        tag: Option<&str>,
532        node_type: Option<&str>,
533        project: Option<&str>,
534        limit: usize,
535    ) -> Result<Vec<GraphNode>> {
536        let limit = limit.min(200) as i64;
537        let mut owned: Vec<String> = Vec::new();
538        let mut conds: Vec<String> = Vec::new();
539        if let Some(t) = tag {
540            owned.push(escape_like(t));
541            conds.push(format!(
542                "(',' || tags || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
543                n = owned.len()
544            ));
545        }
546        if let Some(nt) = node_type {
547            owned.push(nt.to_string());
548            conds.push(format!("node_type = ${n}", n = owned.len()));
549        }
550        if let Some(p) = project {
551            owned.push(escape_like(p));
552            conds.push(format!(
553                "(',' || projects || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
554                n = owned.len()
555            ));
556        }
557        let where_clause = if conds.is_empty() {
558            String::new()
559        } else {
560            format!("WHERE {}", conds.join(" AND "))
561        };
562        let params: Vec<&(dyn ToSql + Sync)> =
563            owned.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
564        let nodes = self.nodes_tbl();
565        let sql = format!(
566            "SELECT {NODE_COLUMNS} FROM {nodes} {where_clause} ORDER BY updated DESC LIMIT {limit}"
567        );
568        let mut c = self.lock();
569        let rows = c.query(&sql, &params).map_err(pg_err)?;
570        Ok(rows.iter().map(row_to_node).collect())
571    }
572
573    fn smart_recall(
574        &self,
575        project: Option<&str>,
576        hint: Option<&str>,
577        limit: usize,
578    ) -> Result<Vec<ScoredNode>> {
579        let now_secs = SystemTime::now()
580            .duration_since(UNIX_EPOCH)
581            .unwrap_or_default()
582            .as_secs();
583
584        // FTS match set (ILIKE), used as a binary boost signal.
585        let fts_ids: HashSet<String> = match hint {
586            Some(h) if !h.is_empty() => self
587                .search_nodes(h, limit * 4)?
588                .into_iter()
589                .map(|n| n.id)
590                .collect(),
591            _ => HashSet::new(),
592        };
593
594        // Candidate fetch (broad set), excluding stale nodes.
595        let candidate_limit = (limit * 4).max(40) as i64;
596        let mut owned: Vec<String> = Vec::new();
597        let mut conds: Vec<String> = vec!["(',' || tags || ',') NOT ILIKE '%,stale,%'".to_string()];
598        if let Some(p) = project {
599            owned.push(escape_like(p));
600            conds.push(format!(
601                "(',' || projects || ',') ILIKE ('%,' || ${n} || ',%') ESCAPE '\\'",
602                n = owned.len()
603            ));
604        }
605        let where_clause = conds.join(" AND ");
606        let params: Vec<&(dyn ToSql + Sync)> =
607            owned.iter().map(|s| -> &(dyn ToSql + Sync) { s }).collect();
608        let nodes = self.nodes_tbl();
609        let edges = self.edges_tbl();
610        let sql = format!(
611            "SELECT {NODE_COLUMNS} FROM {nodes} WHERE {where_clause} ORDER BY importance DESC, updated DESC LIMIT {candidate_limit}"
612        );
613        let mut c = self.lock();
614        let rows = c.query(&sql, &params).map_err(pg_err)?;
615        let candidates: Vec<GraphNode> = rows.iter().map(row_to_node).collect();
616
617        // Composite scoring — identical weights/recency as the SQLite backend.
618        let mut scored: Vec<ScoredNode> = candidates
619            .into_iter()
620            .map(|node| {
621                let recency = compute_recency(&node.updated, now_secs);
622                let importance = node.importance;
623                let access_freq = (node.access_count.max(0) as f64 / 20.0).min(1.0);
624                let fts_match = if fts_ids.contains(&node.id) { 1.0 } else { 0.0 };
625                let score = W_RECENCY * recency
626                    + W_IMPORTANCE * importance
627                    + W_ACCESS * access_freq
628                    + W_FTS * fts_match;
629                ScoredNode { node, score }
630            })
631            .collect();
632        scored.sort_by(|a, b| {
633            b.score
634                .partial_cmp(&a.score)
635                .unwrap_or(std::cmp::Ordering::Equal)
636        });
637        scored.truncate(limit);
638
639        // Graph-boost pass: PageRank centrality over the induced subgraph of
640        // the top candidates. Shares the pagerank math with the SQLite recall
641        // path (zero drift) — only the edge-load SQL differs per backend.
642        if scored.len() > 1 {
643            const MAX_GRAPH_BOOST_PARTICIPANTS: usize = 100;
644            let candidate_ids: Vec<String> = scored
645                .iter()
646                .take(MAX_GRAPH_BOOST_PARTICIPANTS)
647                .map(|sn| sn.node.id.clone())
648                .collect();
649            let n = candidate_ids.len();
650            let l1: String = (1..=n)
651                .map(|i| format!("${i}"))
652                .collect::<Vec<_>>()
653                .join(",");
654            let l2: String = ((n + 1)..=(2 * n))
655                .map(|i| format!("${i}"))
656                .collect::<Vec<_>>()
657                .join(",");
658            let sql = format!(
659                "SELECT id, source, target, relation, weight, ts FROM {edges} WHERE source IN ({l1}) AND target IN ({l2})"
660            );
661            let mut bp: Vec<&(dyn ToSql + Sync)> = Vec::with_capacity(2 * n);
662            for id in &candidate_ids {
663                bp.push(id);
664            }
665            for id in &candidate_ids {
666                bp.push(id);
667            }
668            let sub_edges: Vec<GraphEdge> = match c.query(&sql, &bp) {
669                Ok(rows) => rows
670                    .iter()
671                    .map(|r| GraphEdge {
672                        id: r.get(0),
673                        source: r.get(1),
674                        target: r.get(2),
675                        relation: r.get(3),
676                        weight: r.get(4),
677                        ts: r.get(5),
678                    })
679                    .collect(),
680                Err(_) => Vec::new(),
681            };
682            let csr = CsrGraph::from_edges(&candidate_ids, &sub_edges);
683            let pr = pagerank_default(&csr);
684            let max_pr = pr.iter().copied().fold(0.0_f64, f64::max).max(1e-12);
685            let pr_map: std::collections::HashMap<String, f64> = candidate_ids
686                .iter()
687                .zip(pr.iter())
688                .map(|(id, &s)| (id.clone(), s / max_pr))
689                .collect();
690            for sn in &mut scored {
691                let boost = pr_map.get(&sn.node.id).copied().unwrap_or(0.0);
692                sn.score += W_GRAPH * boost;
693            }
694            scored.sort_by(|a, b| {
695                b.score
696                    .partial_cmp(&a.score)
697                    .unwrap_or(std::cmp::Ordering::Equal)
698            });
699        }
700
701        // Touch retrieved nodes in a single statement (access_count++,
702        // accessed_at = now) rather than N round-trips.
703        if !scored.is_empty() {
704            let now = now_iso();
705            let ids: Vec<&str> = scored.iter().map(|sn| sn.node.id.as_str()).collect();
706            let placeholders: String = (0..ids.len())
707                .map(|i| format!("${}", i + 2))
708                .collect::<Vec<_>>()
709                .join(",");
710            let mut params: Vec<&(dyn ToSql + Sync)> = Vec::with_capacity(ids.len() + 1);
711            params.push(&now);
712            for id in &ids {
713                params.push(id);
714            }
715            let sql = format!(
716                "UPDATE {nodes} SET access_count = access_count + 1, accessed_at = $1 WHERE id IN ({placeholders})"
717            );
718            let _ = c.execute(&sql, &params);
719        }
720
721        Ok(scored)
722    }
723
724    fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
725        let edges = self.edges_tbl();
726        let mut c = self.lock();
727        let depth_v = depth as i32;
728        let params: [&(dyn ToSql + Sync); 2] = [&start_id, &depth_v];
729        let rows = c
730            .query(
731                // PostgreSQL requires a single recursive term: the bidirectional
732                // seed is folded into a subquery, then one recursive step follows
733                // edges in either direction (CASE picks the opposite endpoint).
734                &format!(
735                    "WITH RECURSIVE bfs(node_id, lvl) AS (
736                    SELECT nb.node_id, 1 FROM (
737                        SELECT target AS node_id FROM {edges} WHERE source = $1
738                        UNION
739                        SELECT source AS node_id FROM {edges} WHERE target = $1
740                    ) nb
741                    UNION
742                    SELECT CASE WHEN e.source = bfs.node_id THEN e.target ELSE e.source END,
743                           bfs.lvl + 1
744                    FROM bfs
745                    JOIN {edges} e ON e.source = bfs.node_id OR e.target = bfs.node_id
746                    WHERE bfs.lvl < $2
747                )
748                SELECT DISTINCT node_id FROM bfs WHERE node_id <> $1 LIMIT 500"
749                ),
750                &params,
751            )
752            .map_err(pg_err)?;
753        Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect())
754    }
755
756    fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
757        let params: [&(dyn ToSql + Sync); 6] = [
758            &edge.id,
759            &edge.source,
760            &edge.target,
761            &edge.relation,
762            &edge.weight,
763            &edge.ts,
764        ];
765        let edges = self.edges_tbl();
766        let mut c = self.lock();
767        c.execute(
768            &format!(
769                "INSERT INTO {edges} (id, source, target, relation, weight, ts)
770             VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
771            ),
772            &params,
773        )
774        .map_err(pg_err)?;
775        Ok(())
776    }
777
778    fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
779        if edges.is_empty() {
780            return Ok(());
781        }
782        let edges_tbl = self.edges_tbl();
783        const CHUNK: usize = 5000;
784        let mut c = self.lock();
785        for chunk in edges.chunks(CHUNK) {
786            // Each chunk is its own transaction — bounds WAL growth and keeps a
787            // partial index build recoverable. `ON CONFLICT DO NOTHING` preserves
788            // the per-row idempotency of `append_edge`.
789            let mut tx = c.transaction().map_err(pg_err)?;
790            {
791                let stmt = tx
792                    .prepare(&format!(
793                        "INSERT INTO {edges_tbl} (id, source, target, relation, weight, ts)
794                         VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
795                    ))
796                    .map_err(pg_err)?;
797                for e in chunk {
798                    let params: [&(dyn ToSql + Sync); 6] =
799                        [&e.id, &e.source, &e.target, &e.relation, &e.weight, &e.ts];
800                    tx.execute(&stmt, &params).map_err(pg_err)?;
801                }
802            }
803            tx.commit().map_err(pg_err)?;
804        }
805        Ok(())
806    }
807
808    fn edges_for_node_dir(
809        &self,
810        node_id: &str,
811        dir: EdgeDirection,
812        relation: Option<&str>,
813    ) -> Result<Vec<GraphEdge>> {
814        let edges = self.edges_tbl();
815        let mut c = self.lock();
816        let dir_clause = match dir {
817            EdgeDirection::Out => "source = $1",
818            EdgeDirection::In => "target = $1",
819            EdgeDirection::Both => "(source = $1 OR target = $1)",
820        };
821        let rows = if let Some(r) = relation {
822            let sql = format!(
823                "SELECT id, source, target, relation, weight, ts FROM {edges} \
824                 WHERE {dir_clause} AND relation = $2 ORDER BY weight DESC"
825            );
826            c.query(&sql, &[&node_id, &r]).map_err(pg_err)?
827        } else {
828            let sql = format!(
829                "SELECT id, source, target, relation, weight, ts FROM {edges} \
830                 WHERE {dir_clause} ORDER BY weight DESC"
831            );
832            c.query(&sql, &[&node_id]).map_err(pg_err)?
833        };
834        Ok(rows.iter().map(row_to_edge).collect())
835    }
836
837    fn neighbors_weighted(
838        &self,
839        seed_ids: &[String],
840        dir: EdgeDirection,
841        relation: Option<&str>,
842    ) -> Result<Vec<(String, f64)>> {
843        if seed_ids.is_empty() {
844            return Ok(vec![]);
845        }
846        let edges = self.edges_tbl();
847        const MAX_SEEDS: usize = 100;
848        let seed_ids = if seed_ids.len() > MAX_SEEDS {
849            &seed_ids[..MAX_SEEDS]
850        } else {
851            seed_ids
852        };
853        let seed_arr: Vec<String> = seed_ids.to_vec();
854        let seed_set: HashSet<&str> = seed_ids.iter().map(String::as_str).collect();
855        let mut c = self.lock();
856        let mut weights: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
857
858        // Walk each directional half (source side, target side, or both).
859        let halves: &[&str] = match dir {
860            EdgeDirection::Out => &["source"],
861            EdgeDirection::In => &["target"],
862            EdgeDirection::Both => &["source", "target"],
863        };
864        for &follow in halves {
865            // `follow` is the column the seed matches; the neighbor is the
866            // opposite endpoint.
867            let select_col = if follow == "source" {
868                "target"
869            } else {
870                "source"
871            };
872            let rel_clause = relation.map(|_| " AND relation = $2").unwrap_or("");
873            let sql = format!(
874                "SELECT {select_col} AS nb, SUM(weight) AS w FROM {edges} \
875                 WHERE {follow} = ANY($1){rel_clause} GROUP BY {select_col}"
876            );
877            let rows = if let Some(r) = relation {
878                c.query(&sql, &[&seed_arr, &r]).map_err(pg_err)?
879            } else {
880                c.query(&sql, &[&seed_arr]).map_err(pg_err)?
881            };
882            for row in &rows {
883                let nb: String = row.get(0);
884                let w: f64 = row.get(1);
885                if !seed_set.contains(nb.as_str()) {
886                    *weights.entry(nb).or_default() += w;
887                }
888            }
889        }
890
891        let mut result: Vec<(String, f64)> = weights.into_iter().collect();
892        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
893        Ok(result)
894    }
895
896    fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
897        let edges = self.edges_tbl();
898        let mut c = self.lock();
899        let params: [&(dyn ToSql + Sync); 1] = [&node_id];
900        let rows = c
901            .query(
902                &format!(
903                    "SELECT id, source, target, relation, weight, ts FROM {edges} WHERE source = $1 OR target = $1"
904                ),
905                &params,
906            )
907            .map_err(pg_err)?;
908        Ok(rows.iter().map(row_to_edge).collect())
909    }
910
911    fn delete_edge(&self, id: &str) -> Result<bool> {
912        let edges = self.edges_tbl();
913        let mut c = self.lock();
914        let params: [&(dyn ToSql + Sync); 1] = [&id];
915        let n = c
916            .execute(&format!("DELETE FROM {edges} WHERE id = $1"), &params)
917            .map_err(pg_err)?;
918        Ok(n > 0)
919    }
920
921    fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
922        let edges = self.edges_tbl();
923        let mut c = self.lock();
924        let params: [&(dyn ToSql + Sync); 1] = [&node_id];
925        c.execute(
926            &format!("DELETE FROM {edges} WHERE source = $1 OR target = $1"),
927            &params,
928        )
929        .map_err(pg_err)?;
930        Ok(())
931    }
932
933    fn current_version(&self) -> Result<u32> {
934        let meta = self.meta_tbl();
935        let mut c = self.lock();
936        let row = c
937            .query_opt(
938                &format!("SELECT value FROM {meta} WHERE key = 'graph_schema_version'"),
939                &[],
940            )
941            .map_err(pg_err)?;
942        Ok(row
943            .map(|r| r.get::<_, String>(0))
944            .and_then(|s| s.parse().ok())
945            .unwrap_or(0))
946    }
947
948    fn migrate(&self) -> Result<u32> {
949        let mut c = self.lock();
950        let current = schema_version(&mut c, &self.table_prefix)?;
951        migrate(&mut c, current, &self.table_prefix)
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use crate::graph::{GraphBackend, SqliteGraph};
959    use postgres::{Config, NoTls};
960
961    const TEST_DB: &str = "llm_kernel_pg_test";
962
963    fn sample_node(id: &str) -> GraphNode {
964        GraphNode {
965            id: id.to_string(),
966            node_type: "concept".to_string(),
967            title: format!("Node {id}"),
968            body: "pg backend test body".to_string(),
969            tags: vec!["backend".to_string()],
970            projects: vec![],
971            agents: vec![],
972            created: "2026-01-01T00:00:00Z".to_string(),
973            updated: "2026-01-01T00:00:00Z".to_string(),
974            importance: 0.5,
975            access_count: 0,
976            accessed_at: String::new(),
977        }
978    }
979
980    /// Bring up a throwaway DB, run `body` against a fresh `PgGraph`, then tear it down.
981    fn with_test_db<F: FnOnce(&PgGraph)>(body: F) {
982        let base = std::env::var("LLMKERNEL_PG_URL").expect("LLMKERNEL_PG_URL set");
983        let admin_cfg: Config = base
984            .parse()
985            .expect("LLMKERNEL_PG_URL is a valid libpq connstring");
986        {
987            let mut admin = admin_cfg.connect(NoTls).expect("admin connect");
988            let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {TEST_DB}"));
989            admin
990                .batch_execute(&format!("CREATE DATABASE {TEST_DB}"))
991                .expect("create test db");
992        }
993        let mut test_cfg = admin_cfg.clone();
994        test_cfg.dbname(TEST_DB);
995        let graph = PgGraph::connect_config(&test_cfg).expect("connect to test db");
996        body(&graph);
997        drop(graph);
998        let mut admin = admin_cfg.connect(NoTls).expect("admin reconnect");
999        let _ = admin.batch_execute(&format!("DROP DATABASE IF EXISTS {TEST_DB}"));
1000    }
1001
1002    /// Offline (no server): the ILIKE pattern transform escapes LIKE wildcards
1003    /// and wraps each term — covers `tests-api-1` SQL-input coverage.
1004    #[test]
1005    fn search_patterns_escapes_and_wraps() {
1006        assert!(search_patterns("").is_empty());
1007        assert_eq!(search_patterns("rust"), vec!["%rust%".to_string()]);
1008        // LIKE wildcards (`%`, `_`, `\`) are escaped inside the term.
1009        assert_eq!(search_patterns("100%"), vec!["%100\\%%".to_string()]);
1010        assert_eq!(search_patterns("a_b"), vec!["%a\\_b%".to_string()]);
1011        // Multiple whitespace-separated terms → multiple patterns.
1012        assert_eq!(
1013            search_patterns("rust db"),
1014            vec!["%rust%".to_string(), "%db%".to_string()]
1015        );
1016    }
1017
1018    /// Offline (no server): the prefix validator accepts empty / ASCII
1019    /// alphanumeric+underscore names (first char not a digit) and rejects
1020    /// everything else — the guard that keeps prefix interpolation
1021    /// injection-safe.
1022    #[test]
1023    fn is_identifier_safe_validation() {
1024        // Accepted: empty (default), and ASCII letter/underscore-led names.
1025        assert!(is_identifier_safe(""));
1026        assert!(is_identifier_safe("lk_"));
1027        assert!(is_identifier_safe("graph1"));
1028        assert!(is_identifier_safe("_x"));
1029        assert!(is_identifier_safe("ABC_123"));
1030        // Rejected: a digit-led prefix would form an invalid unquoted identifier.
1031        assert!(!is_identifier_safe("1lk"));
1032        // Rejected: whitespace, punctuation, quotes, SQL metacharacters, CJK.
1033        assert!(!is_identifier_safe("lk nodes"));
1034        assert!(!is_identifier_safe("lk;"));
1035        assert!(!is_identifier_safe("lk' OR 1=1--"));
1036        assert!(!is_identifier_safe("lk-bad"));
1037        assert!(!is_identifier_safe("lk.bad"));
1038        assert!(!is_identifier_safe("데이터"));
1039    }
1040
1041    /// `connect_native_tls` against a live PostgreSQL configured for TLS
1042    /// (skips without `LLMKERNEL_PG_URL`). If the server does not offer TLS,
1043    /// `native-tls` negotiation fails and `connect_native_tls` surfaces that
1044    /// as an `Err` rather than panicking — asserting only that shape, since
1045    /// most local/CI Postgres instances run without TLS configured.
1046    #[cfg(feature = "graph-pg-tls")]
1047    #[test]
1048    fn connect_native_tls_returns_result_not_panic() {
1049        let base = match std::env::var("LLMKERNEL_PG_URL") {
1050            Ok(u) => u,
1051            Err(_) => {
1052                eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1053                return;
1054            }
1055        };
1056        match PgGraph::connect_native_tls(&base) {
1057            Ok(g) => {
1058                assert_eq!(g.current_version().unwrap(), GRAPH_SCHEMA_VERSION);
1059            }
1060            Err(e) => {
1061                eprintln!("connect_native_tls returned Err as expected on a non-TLS server: {e}");
1062            }
1063        }
1064    }
1065
1066    /// Full `GraphBackend` conformance against a live PostgreSQL (skips without
1067    /// `LLMKERNEL_PG_URL`).
1068    #[test]
1069    fn live_pg_graph_backend_conformance() {
1070        if std::env::var("LLMKERNEL_PG_URL").is_err() {
1071            eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1072            return;
1073        }
1074        with_test_db(|g| {
1075            assert_eq!(g.current_version().unwrap(), GRAPH_SCHEMA_VERSION);
1076            assert_eq!(g.migrate().unwrap(), GRAPH_SCHEMA_VERSION);
1077
1078            let dyn_g: &dyn GraphBackend = g;
1079            assert!(dyn_g.read_node("n1").unwrap().is_none());
1080
1081            g.upsert_node(&sample_node("rust")).unwrap();
1082            let loaded = g.read_node("rust").unwrap().unwrap();
1083            assert_eq!(loaded.title, "Node rust");
1084            assert_eq!(loaded.tags, vec!["backend".to_string()]);
1085
1086            let mut updated = sample_node("rust");
1087            updated.title = "Rust ownership".into();
1088            updated.body = "borrow checker rules".into();
1089            g.upsert_node(&updated).unwrap();
1090            assert_eq!(
1091                g.read_node("rust").unwrap().unwrap().title,
1092                "Rust ownership"
1093            );
1094
1095            assert!(g.delete_node("rust").unwrap());
1096            assert!(!g.delete_node("rust").unwrap());
1097            assert!(g.read_node("rust").unwrap().is_none());
1098
1099            let mut n = sample_node("rust");
1100            n.title = "Rust ownership model".into();
1101            n.body = "borrow checker rules".into();
1102            n.tags = vec!["rust".into(), "memory".into()];
1103            g.upsert_node(&n).unwrap();
1104            let mut other = sample_node("py");
1105            other.title = "Python GIL".into();
1106            g.upsert_node(&other).unwrap();
1107
1108            let hits = g.search_nodes("rust", 10).unwrap();
1109            assert_eq!(hits.len(), 1);
1110            assert_eq!(hits[0].id, "rust");
1111
1112            let tagged = g.query_nodes(Some("rust"), None, None, 10).unwrap();
1113            assert_eq!(tagged.len(), 1);
1114            assert_eq!(tagged[0].id, "rust");
1115
1116            g.append_edge(&GraphEdge {
1117                id: "e1".into(),
1118                source: "rust".into(),
1119                target: "py".into(),
1120                relation: "related".into(),
1121                weight: 1.0,
1122                ts: "2026-01-01T00:00:00Z".into(),
1123            })
1124            .unwrap();
1125            assert_eq!(g.edges_for_node("rust").unwrap().len(), 1);
1126            assert!(
1127                g.related_nodes("rust", 2)
1128                    .unwrap()
1129                    .contains(&"py".to_string())
1130            );
1131
1132            // correctness-1: a fresh-id edge with a duplicate (source, target,
1133            // relation) triple is IGNORED (ON CONFLICT DO NOTHING catches any
1134            // unique violation, matching SQLite's INSERT OR IGNORE) — no hard
1135            // unique-violation error on PostgreSQL.
1136            g.append_edge(&GraphEdge {
1137                id: "e1dup".into(),
1138                source: "rust".into(),
1139                target: "py".into(),
1140                relation: "related".into(),
1141                weight: 2.0,
1142                ts: "2026-01-02T00:00:00Z".into(),
1143            })
1144            .unwrap();
1145            assert_eq!(
1146                g.edges_for_node("rust").unwrap().len(),
1147                1,
1148                "duplicate (src,tgt,rel) edge ignored"
1149            );
1150
1151            // correctness-2: a self-loop on the start node is excluded from
1152            // related_nodes (PostgreSQL correctly prunes the start node; the
1153            // SQLite backend has a pre-existing quirk that leaks it).
1154            g.append_edge(&GraphEdge {
1155                id: "eloop".into(),
1156                source: "rust".into(),
1157                target: "rust".into(),
1158                relation: "self".into(),
1159                weight: 1.0,
1160                ts: "2026-01-01T00:00:00Z".into(),
1161            })
1162            .unwrap();
1163            let related = g.related_nodes("rust", 2).unwrap();
1164            assert!(
1165                !related.contains(&"rust".to_string()),
1166                "start node excluded even with a self-loop"
1167            );
1168
1169            let recalled = g.smart_recall(None, Some("ownership"), 5).unwrap();
1170            assert!(recalled.iter().any(|s| s.node.id == "rust"));
1171            let after = g.read_node("rust").unwrap().unwrap();
1172            assert!(after.access_count >= 1, "access_count incremented");
1173        });
1174    }
1175
1176    /// Same database, two prefixes: writes under one prefix are invisible to
1177    /// the other because they land in separate `nodes` / `edges` / `_meta`
1178    /// table sets. Skips without `LLMKERNEL_PG_URL`.
1179    #[test]
1180    fn live_prefix_isolation() {
1181        if std::env::var("LLMKERNEL_PG_URL").is_err() {
1182            eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1183            return;
1184        }
1185        with_test_db(|g_default| {
1186            // Default prefix ("") graph already has its schema; seed it.
1187            g_default.upsert_node(&sample_node("default_only")).unwrap();
1188            assert_eq!(
1189                g_default.read_node("default_only").unwrap().unwrap().title,
1190                "Node default_only"
1191            );
1192
1193            // Open a SECOND backend on the SAME database with prefix "lk_".
1194            let base = std::env::var("LLMKERNEL_PG_URL").unwrap();
1195            let admin_cfg: Config = base.parse().expect("valid connstring");
1196            let mut cfg = admin_cfg.clone();
1197            cfg.dbname(TEST_DB);
1198            let g_prefixed =
1199                PgGraph::connect_config_with_prefix(&cfg, "lk_").expect("prefixed connect");
1200
1201            // Cross-prefix isolation: neither sees the other's nodes.
1202            assert!(g_prefixed.read_node("default_only").unwrap().is_none());
1203            g_prefixed.upsert_node(&sample_node("lk_only")).unwrap();
1204            assert!(g_default.read_node("lk_only").unwrap().is_none());
1205            assert_eq!(
1206                g_prefixed.read_node("lk_only").unwrap().unwrap().title,
1207                "Node lk_only"
1208            );
1209
1210            // Edge isolation across prefixes.
1211            g_prefixed.upsert_node(&sample_node("lk_peer")).unwrap();
1212            g_prefixed
1213                .append_edge(&GraphEdge {
1214                    id: "lk_e1".into(),
1215                    source: "lk_only".into(),
1216                    target: "lk_peer".into(),
1217                    relation: "related".into(),
1218                    weight: 1.0,
1219                    ts: "2026-01-01T00:00:00Z".into(),
1220                })
1221                .unwrap();
1222            assert_eq!(g_prefixed.edges_for_node("lk_only").unwrap().len(), 1);
1223            assert_eq!(g_default.edges_for_node("lk_only").unwrap().len(), 0);
1224
1225            // The prefixed graph tracks its own schema version in lk_meta.
1226            assert!(g_prefixed.current_version().unwrap() >= 2);
1227
1228            // An invalid prefix is rejected at construction (never reaches SQL).
1229            assert!(PgGraph::connect_config_with_prefix(&cfg, "lk; drop").is_err());
1230            assert!(PgGraph::connect_config_with_prefix(&cfg, "1lk").is_err());
1231        });
1232    }
1233
1234    /// SQLite → PostgreSQL migration round-trip through the `GraphBackend` trait
1235    /// (skips without `LLMKERNEL_PG_URL`).
1236    #[test]
1237    fn live_migrate_sqlite_to_postgres_round_trip() {
1238        let base = match std::env::var("LLMKERNEL_PG_URL") {
1239            Ok(u) => u,
1240            Err(_) => {
1241                eprintln!("skipped: LLMKERNEL_PG_URL unset (no live PostgreSQL)");
1242                return;
1243            }
1244        };
1245
1246        let src = SqliteGraph::open_in_memory().expect("sqlite source");
1247        let mut a = sample_node("a");
1248        a.body = "migrate test body".into();
1249        a.tags = vec!["migrate".to_string()];
1250        a.projects = vec!["demo".to_string()];
1251        a.importance = 0.6;
1252        src.upsert_node(&a).unwrap();
1253        src.upsert_node(&sample_node("b")).unwrap();
1254        src.append_edge(&GraphEdge {
1255            id: "e1".into(),
1256            source: "a".into(),
1257            target: "b".into(),
1258            relation: "related".into(),
1259            weight: 1.0,
1260            ts: "2026-01-01T00:00:00Z".into(),
1261        })
1262        .unwrap();
1263        let nodes = src.query_nodes(None, None, None, 200).unwrap();
1264        assert_eq!(nodes.len(), 2);
1265
1266        let admin_cfg: Config = base.parse().expect("valid connstring");
1267        {
1268            let mut admin = admin_cfg.connect(NoTls).expect("admin connect");
1269            let _ = admin.batch_execute("DROP DATABASE IF EXISTS llm_kernel_migrate_test");
1270            admin
1271                .batch_execute("CREATE DATABASE llm_kernel_migrate_test")
1272                .expect("create test db");
1273        }
1274        let mut test_cfg = admin_cfg.clone();
1275        test_cfg.dbname("llm_kernel_migrate_test");
1276        let pg = PgGraph::connect_config(&test_cfg).expect("connect target");
1277
1278        for n in &nodes {
1279            pg.upsert_node(n).unwrap();
1280        }
1281        pg.append_edge(&GraphEdge {
1282            id: "e1".into(),
1283            source: "a".into(),
1284            target: "b".into(),
1285            relation: "related".into(),
1286            weight: 1.0,
1287            ts: "2026-01-01T00:00:00Z".into(),
1288        })
1289        .unwrap();
1290
1291        assert_eq!(pg.list_nodes(100).unwrap().len(), 2);
1292        assert_eq!(pg.list_edges(100).unwrap().len(), 1);
1293        let loaded = pg.read_node("a").unwrap().unwrap();
1294        assert_eq!(loaded.title, "Node a");
1295        assert_eq!(loaded.tags, vec!["migrate".to_string()]);
1296        assert!((loaded.importance - 0.6).abs() < 1e-9);
1297
1298        drop(pg);
1299        let mut admin = admin_cfg.connect(NoTls).expect("admin reconnect");
1300        let _ = admin.batch_execute("DROP DATABASE IF EXISTS llm_kernel_migrate_test");
1301    }
1302}