Skip to main content

mcpmem_core/
graph.rs

1use rustc_hash::FxHashMap;
2use std::collections::{HashSet, VecDeque};
3use std::num::NonZeroUsize;
4use std::path::Path;
5use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
6use std::time::Duration;
7
8use parking_lot::{Mutex, MutexGuard};
9use rusqlite::{Connection, OpenFlags, params};
10
11use crate::errors::{MCSError, Result};
12use crate::mutation::{
13    MutationContext, MutationRequest, MutationResult, MutationService, ObservationUpdate,
14};
15use crate::storage::{Durability, SqliteTuning};
16use crate::types::{
17    Degree, Entity, EntityDescription, EntityInput, Observation, ObservationInput, Relation,
18};
19
20/// Single SQL projection for every full graph JSON read. Alias `o` is an observation row.
21const OBSERVATION_JSON: &str = "json_object('body',o.body,'createdAtUs',o.created_us,'occurredAtUs',o.occurred_us,'originEntityName',o.origin_entity_name)";
22
23/// Cap on entities/relations collected in a single traversal (DoS guard).
24/// Prevents a dense graph at high depth from allocating unbounded memory.
25const MAX_TRAVERSAL_ENTITIES: usize = 500_000;
26const MAX_TRAVERSAL_RELS: usize = 2_000_000;
27
28fn sqlite_err(e: rusqlite::Error) -> MCSError {
29    MCSError::IoError(std::io::Error::other(e))
30}
31
32const fn is_not_found(e: &rusqlite::Error) -> bool {
33    matches!(e, rusqlite::Error::QueryReturnedNoRows)
34}
35
36#[inline(always)]
37pub fn name_hash(name: &str) -> i64 {
38    let mut h: u64 = 0xcbf29ce484222325;
39    for b in name.bytes() {
40        h ^= u64::from(b);
41        h = h.wrapping_mul(0x100000001b3);
42    }
43    h as i64
44}
45
46fn entity_name_lookup(conn: &Connection, name: &str) -> Result<Option<i64>> {
47    let h = name_hash(name);
48    let mut stmt = conn
49        .prepare_cached("SELECT id FROM entity WHERE name_hash = ?1 AND name = ?2 AND flags = 0")
50        .map_err(sqlite_err)?;
51    match stmt.query_row(params![h, name], |row| row.get::<_, i64>(0)) {
52        Ok(id) => Ok(Some(id)),
53        Err(e) if is_not_found(&e) => Ok(None),
54        Err(e) => Err(sqlite_err(e)),
55    }
56}
57
58/// Read-only type lookup. This never inserts, so it is
59/// safe to call on a `query_only` reader connection. Returns `None` when the
60/// type does not exist.
61fn lookup_type_id(conn: &Connection, type_name: &str, kind: i64) -> Option<i64> {
62    conn.prepare_cached("SELECT id FROM type_dict WHERE kind = ?1 AND name = ?2")
63        .ok()?
64        .query_row(params![kind, type_name], |row| row.get::<_, i64>(0))
65        .ok()
66}
67
68fn read_graph_stat(conn: &Connection, key: &str) -> Result<i64> {
69    conn.query_row(
70        "SELECT value FROM graph_stat WHERE key = ?1",
71        params![key],
72        |row| row.get(0),
73    )
74    .map_err(sqlite_err)
75}
76
77fn select_all_types(conn: &Connection, kind: i64) -> Result<Vec<(String, usize)>> {
78    let mut stmt = conn
79        .prepare_cached(
80            "SELECT name, count FROM type_dict WHERE kind = ?1 AND count > 0 ORDER BY count DESC",
81        )
82        .map_err(sqlite_err)?;
83    let rows = stmt
84        .query_map(params![kind], |row| {
85            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
86        })
87        .map_err(sqlite_err)?
88        .filter_map(|r| r.ok())
89        .collect();
90    Ok(rows)
91}
92
93/// `(name, count, desc)` for every registered type of `kind`: one that has
94/// members (`count > 0`), or one that a description marks as intentional even
95/// before its first member exists. The list tools expose this registry view;
96/// suggestions keep the stricter count-only source above.
97fn select_type_catalog(
98    conn: &Connection,
99    kind: i64,
100) -> Result<Vec<(String, usize, Option<String>)>> {
101    let mut stmt = conn
102        .prepare_cached(
103            "SELECT name, count, desc FROM type_dict
104             WHERE kind = ?1 AND (count > 0 OR desc IS NOT NULL)
105             ORDER BY count DESC",
106        )
107        .map_err(sqlite_err)?;
108    let rows = stmt
109        .query_map(params![kind], |row| {
110            Ok((
111                row.get::<_, String>(0)?,
112                row.get::<_, i64>(1)? as usize,
113                row.get::<_, Option<String>>(2)?,
114            ))
115        })
116        .map_err(sqlite_err)?
117        .filter_map(|r| r.ok())
118        .collect();
119    Ok(rows)
120}
121
122/// Comma-separated decimal list of ids for an inline `IN (...)` / `VALUES`
123/// clause. The ids are `i64` row ids read straight from the database — never
124/// user text — so inlining them as SQL literals is injection-safe and, unlike
125/// bound `?` parameters, is *not* subject to SQLite's `SQLITE_MAX_VARIABLE_NUMBER`
126/// (~32k) ceiling. Traversals and pages over large id/relation sets can thus
127/// build one statement instead of overflowing the parameter limit (which used to
128/// make the query error out and be silently swallowed into an empty result).
129fn int_csv(ids: &[i64]) -> String {
130    use std::fmt::Write as _;
131    let mut s = String::with_capacity(ids.len() * 8);
132    for (i, id) in ids.iter().enumerate() {
133        if i > 0 {
134            s.push(',');
135        }
136        let _ = write!(s, "{id}");
137    }
138    s
139}
140
141/// Build a `(from,to,type), …` literal list for a relation-triple `VALUES` CTE.
142/// Same rationale as [`int_csv`]: the triples are DB row ids, so inlining them is
143/// injection-safe and sidesteps the bound-parameter ceiling that a large
144/// neighbourhood (3 params per edge) would otherwise breach — which previously
145/// errored the relations query and was silently swallowed into `[]`.
146fn rel_values_literal(rels: &HashSet<(i64, i64, i64)>) -> String {
147    use std::fmt::Write as _;
148    let mut s = String::with_capacity(rels.len() * 16);
149    for (i, (f, t, tp)) in rels.iter().enumerate() {
150        if i > 0 {
151            s.push(',');
152        }
153        let _ = write!(s, "({f},{t},{tp})");
154    }
155    s
156}
157
158/// Load full entities (name, type, observations) for a set of ids in a single
159/// query, returning an id→[`Entity`] map. Replaces the old per-id fetches (an
160/// N+1 pattern) on the search path.
161fn batch_entities_by_ids(conn: &Connection, ids: &[i64]) -> FxHashMap<i64, Entity> {
162    let mut map = FxHashMap::default();
163    if ids.is_empty() {
164        return map;
165    }
166    let sql = format!(
167        "SELECT e.id, e.name, t.name,
168                COALESCE((SELECT json_group_array({OBSERVATION_JSON} ORDER BY o.idx, o.id)
169                          FROM observation o WHERE o.entity_id = e.id), '[]')
170         FROM entity e JOIN type_dict t ON t.id = e.type_id
171         WHERE e.id IN ({}) AND e.flags = 0",
172        int_csv(ids)
173    );
174    if let Ok(mut stmt) = conn.prepare(&sql)
175        && let Ok(rows) = stmt.query_map([], |row| {
176            Ok((
177                row.get::<_, i64>(0)?,
178                row.get::<_, String>(1)?,
179                row.get::<_, String>(2)?,
180                row.get::<_, String>(3)?,
181            ))
182        })
183    {
184        for (id, name, etype, obs_json) in rows.flatten() {
185            let observations: Vec<Observation> =
186                serde_json::from_str(&obs_json).unwrap_or_default();
187            map.insert(
188                id,
189                Entity {
190                    name,
191                    entity_type: etype,
192                    observations,
193                },
194            );
195        }
196    }
197    map
198}
199
200/// Like [`batch_entities_by_ids`], but for the viewer's list payloads: skips the
201/// observation *bodies* (the canvas never renders them in bulk — they are
202/// lazy-loaded for the single inspected node) and returns only name, type, and
203/// the denormalised `obs_count`. Keeps the search payload — and the reader-lock
204/// hold — small.
205fn batch_entity_lite_by_ids(
206    conn: &Connection,
207    ids: &[i64],
208) -> FxHashMap<i64, (String, String, i64)> {
209    let mut map = FxHashMap::default();
210    if ids.is_empty() {
211        return map;
212    }
213    let sql = format!(
214        "SELECT e.id, e.name, t.name, e.obs_count
215         FROM entity e JOIN type_dict t ON t.id = e.type_id
216         WHERE e.id IN ({}) AND e.flags = 0",
217        int_csv(ids)
218    );
219    if let Ok(mut stmt) = conn.prepare(&sql)
220        && let Ok(rows) = stmt.query_map([], |row| {
221            Ok((
222                row.get::<_, i64>(0)?,
223                row.get::<_, String>(1)?,
224                row.get::<_, String>(2)?,
225                row.get::<_, i64>(3)?,
226            ))
227        })
228    {
229        for (id, name, etype, oc) in rows.flatten() {
230            map.insert(id, (name, etype, oc));
231        }
232    }
233    map
234}
235
236/// Collect entity ids matching an FTS query — name matches first (by rank), then
237/// observation matches — de-duplicated, each source capped at `cap` rows. Shared
238/// by the MCP and viewer search paths so both agree on ordering.
239fn fts_candidate_ids(conn: &Connection, query: &str, cap: usize) -> Vec<i64> {
240    let mut ids: Vec<i64> = Vec::new();
241    let mut seen: HashSet<i64> = HashSet::new();
242    let cap_i64 = cap as i64;
243
244    if let Ok(mut stmt) =
245        conn.prepare("SELECT rowid FROM name_fts WHERE name_fts MATCH ?1 ORDER BY rank LIMIT ?2")
246        && let Ok(rows) = stmt.query_map(params![query, cap_i64], |row| row.get::<_, i64>(0))
247    {
248        for id in rows.flatten() {
249            if seen.insert(id) {
250                ids.push(id);
251            }
252        }
253    }
254
255    if let Ok(mut stmt) = conn.prepare(
256        "SELECT entity_id FROM obs_fts JOIN observation ON obs_fts.rowid = observation.id
257         WHERE obs_fts MATCH ?1
258         GROUP BY entity_id
259         LIMIT ?2",
260    ) && let Ok(rows) = stmt.query_map(params![query, cap_i64], |row| row.get::<_, i64>(0))
261    {
262        for id in rows.flatten() {
263            if seen.insert(id) {
264                ids.push(id);
265            }
266        }
267    }
268
269    ids
270}
271
272/// Direction of relation traversal.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum Direction {
275    Outgoing,
276    Incoming,
277    Both,
278}
279
280impl Direction {
281    pub fn parse(s: Option<&str>) -> Self {
282        match s {
283            Some("OUTGOING") => Direction::Outgoing,
284            Some("INCOMING") => Direction::Incoming,
285            _ => Direction::Both,
286        }
287    }
288}
289
290/// Escape a string for embedding in JSON, writing directly into the given buffer.
291/// Avoids allocating a temporary `serde_json::Value` for the JSON-RPC wrapper.
292pub fn push_json_str(buf: &mut String, raw: &str) {
293    buf.push('"');
294    let mut start = 0;
295    let bytes = raw.as_bytes();
296    for (i, &b) in bytes.iter().enumerate() {
297        let esc: u8 = match b {
298            b'"' => b'"',
299            b'\\' => b'\\',
300            b'\n' => b'n',
301            b'\r' => b'r',
302            b'\t' => b't',
303            0x08 => b'b',
304            0x0C => b'f',
305            0x00..=0x07 | 0x0B | 0x0E..=0x1F => continue, // escaped below
306            _ => continue,
307        };
308        buf.push_str(&raw[start..i]);
309        buf.push('\\');
310        buf.push(esc as char);
311        start = i + 1;
312    }
313    // Control chars 0x00-0x1F not handled above: escape as \u00XX
314    for (i, &b) in bytes.iter().enumerate().skip(start) {
315        if b <= 0x07 || b == 0x0B || (0x0E..=0x1F).contains(&b) {
316            buf.push_str(&raw[start..i]);
317            write_escape_unicode(buf, b);
318            start = i + 1;
319        }
320    }
321    buf.push_str(&raw[start..]);
322    buf.push('"');
323}
324
325#[inline(never)]
326fn write_escape_unicode(buf: &mut String, b: u8) {
327    use std::fmt::Write;
328    write!(buf, "\\u{:04x}", b).unwrap();
329}
330
331// ── Transaction guard (RAII rollback on error) ─────────────────────────
332
333pub(crate) struct TxGuard<'a> {
334    conn: &'a Connection,
335    done: bool,
336}
337
338impl<'a> TxGuard<'a> {
339    pub(crate) fn begin(conn: &'a Connection) -> Result<Self> {
340        // BEGIN IMMEDIATE acquires the WAL write lock up front rather than
341        // lazily on the first write. This makes the busy-timeout apply to lock
342        // acquisition deterministically and avoids `SQLITE_BUSY_SNAPSHOT`
343        // surprises when readers are concurrently active.
344        conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
345        Ok(Self { conn, done: false })
346    }
347
348    pub(crate) fn commit(mut self) -> Result<()> {
349        self.conn.execute_batch("COMMIT").map_err(sqlite_err)?;
350        self.done = true;
351        Ok(())
352    }
353}
354
355impl Drop for TxGuard<'_> {
356    fn drop(&mut self) {
357        if !self.done {
358            let _ = self.conn.execute_batch("ROLLBACK");
359        }
360    }
361}
362
363// ── Reader pool ───────────────────────────────────────────────────────────
364
365/// A small fixed pool of `query_only` SQLite connections used for read
366/// operations. WAL mode permits any number of concurrent readers alongside the
367/// single writer, so spreading reads across several connections lets them run
368/// in parallel instead of serializing on the writer's mutex.
369struct ReaderPool {
370    conns: Vec<Mutex<Connection>>,
371    next: AtomicUsize,
372}
373
374impl ReaderPool {
375    /// Acquire a reader connection. Fast path: grab the first idle one. If every
376    /// connection is busy, block on a round-robin pick so callers still make
377    /// progress (and never spin).
378    fn get(&self) -> MutexGuard<'_, Connection> {
379        for c in &self.conns {
380            if let Some(g) = c.try_lock() {
381                return g;
382            }
383        }
384        let i = self.next.fetch_add(1, Ordering::Relaxed) % self.conns.len();
385        self.conns[i].lock()
386    }
387}
388
389// ── GraphHandle ──────────────────────────────────────────────────────────
390
391pub struct GraphHandle {
392    /// The single read-write connection. SQLite allows only one writer, so all
393    /// mutations serialize here.
394    pub(crate) writer: Mutex<Connection>,
395    /// Pool of `query_only` connections for concurrent reads (WAL).
396    readers: ReaderPool,
397    seq_entity: AtomicI64,
398    seq_obs: AtomicI64,
399}
400
401/// Open one `query_only` reader connection against an existing WAL database.
402///
403/// The connection is opened read-write at the OS level (so it can attach to the
404/// `-shm` wal-index — SQLite cannot read a WAL database through a pure
405/// `SQLITE_OPEN_READ_ONLY` handle) and then locked to reads with
406/// `PRAGMA query_only = ON`, which makes any accidental write error out.
407fn open_reader(path: &Path, tuning: &SqliteTuning) -> Result<Connection> {
408    let conn = Connection::open_with_flags(
409        path,
410        OpenFlags::SQLITE_OPEN_READ_WRITE
411            | OpenFlags::SQLITE_OPEN_NO_MUTEX
412            | OpenFlags::SQLITE_OPEN_URI,
413    )
414    .map_err(sqlite_err)?;
415    conn.busy_timeout(Duration::from_millis(tuning.busy_timeout_ms))
416        .map_err(sqlite_err)?;
417    conn.execute_batch(&format!(
418        "PRAGMA query_only   = ON;
419         PRAGMA cache_size   = -{};
420         PRAGMA temp_store   = MEMORY;
421         PRAGMA mmap_size    = {};",
422        tuning.cache_size_kb, tuning.mmap_size
423    ))
424    .map_err(sqlite_err)?;
425    Ok(conn)
426}
427
428impl GraphHandle {
429    pub fn new(
430        path: &Path,
431        durability: Durability,
432        tuning: SqliteTuning,
433        _lru_cache_size: NonZeroUsize,
434        read_pool_size: usize,
435    ) -> Result<Self> {
436        let conn = Connection::open(path).map_err(sqlite_err)?;
437        // Apply the busy handler through the API so it is in force for every
438        // subsequent statement (including schema creation and BEGIN IMMEDIATE).
439        conn.busy_timeout(Duration::from_millis(tuning.busy_timeout_ms))
440            .map_err(sqlite_err)?;
441
442        // `page_size` and `auto_vacuum` are fixed when the database first gets
443        // content, and `page_size` additionally must precede `journal_mode=WAL`.
444        // Set both up front on this connection, before any table is created, so
445        // they take effect on a fresh database. On an existing database they are
446        // silently ignored (would require VACUUM to change).
447        conn.execute_batch(&format!(
448            "PRAGMA page_size    = {};
449             PRAGMA auto_vacuum  = INCREMENTAL;",
450            tuning.page_size
451        ))
452        .map_err(sqlite_err)?;
453
454        conn.execute_batch(&format!(
455            "PRAGMA journal_mode = WAL;
456             PRAGMA foreign_keys = OFF;
457             PRAGMA cache_size    = -{};
458             PRAGMA temp_store    = MEMORY;
459             PRAGMA busy_timeout  = {};
460             PRAGMA synchronous   = NORMAL;
461             PRAGMA journal_size_limit = {};",
462            tuning.cache_size_kb, tuning.busy_timeout_ms, tuning.journal_size_limit
463        ))
464        .map_err(sqlite_err)?;
465
466        crate::schema::initialize_database(&conn)?;
467
468        conn.execute_batch(&format!("PRAGMA mmap_size = {};", tuning.mmap_size))
469            .map_err(sqlite_err)?;
470
471        let sync_pragma = match durability {
472            Durability::Sync => "PRAGMA synchronous = FULL",
473            Durability::Async => "PRAGMA synchronous = NORMAL",
474        };
475        conn.execute_batch(sync_pragma).map_err(sqlite_err)?;
476
477        // Bound the cost of `PRAGMA optimize` (here and in maintenance) so a
478        // large database cannot stall startup/maintenance analyzing every index.
479        conn.execute_batch("PRAGMA analysis_limit = 400;")
480            .map_err(sqlite_err)?;
481
482        conn.execute_batch("PRAGMA optimize;").map_err(sqlite_err)?;
483
484        let seq_entity = read_graph_stat(&conn, "entity_seq").unwrap_or(0);
485        let seq_obs = read_graph_stat(&conn, "obs_seq").unwrap_or(0);
486
487        // Open the reader pool against the now-initialized database. At least one
488        // reader is always created.
489        let pool_size = read_pool_size.max(1);
490        let mut conns = Vec::with_capacity(pool_size);
491        for _ in 0..pool_size {
492            conns.push(Mutex::new(open_reader(path, &tuning)?));
493        }
494        let readers = ReaderPool {
495            conns,
496            next: AtomicUsize::new(0),
497        };
498
499        Ok(Self {
500            writer: Mutex::new(conn),
501            readers,
502            seq_entity: AtomicI64::new(seq_entity),
503            seq_obs: AtomicI64::new(seq_obs),
504        })
505    }
506
507    pub(crate) fn next_entity_id(&self) -> i64 {
508        self.seq_entity.fetch_add(1, Ordering::Relaxed) + 1
509    }
510
511    /// Refresh while holding BEGIN IMMEDIATE; another process may have advanced
512    /// the durable counters since this handle opened its connection.
513    pub(crate) fn refresh_seqs(&self, conn: &Connection) -> Result<()> {
514        self.seq_entity
515            .fetch_max(read_graph_stat(conn, "entity_seq")?, Ordering::Relaxed);
516        self.seq_obs
517            .fetch_max(read_graph_stat(conn, "obs_seq")?, Ordering::Relaxed);
518        Ok(())
519    }
520
521    pub(crate) fn next_obs_id(&self) -> i64 {
522        self.seq_obs.fetch_add(1, Ordering::Relaxed) + 1
523    }
524
525    fn get_entity_id(&self, conn: &Connection, name: &str) -> Result<Option<(i64, i64, i64, i64)>> {
526        use rusqlite::OptionalExtension;
527        conn.query_row(
528            "SELECT id, type_id, out_deg, in_deg FROM entity WHERE name_hash = ?1 AND name = ?2 AND flags = 0",
529            params![name_hash(name), name],
530            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
531        ).optional().map_err(sqlite_err)
532    }
533
534    pub(crate) fn sync_seqs(&self, conn: &Connection) -> Result<()> {
535        let seq_e = self.seq_entity.load(Ordering::Relaxed);
536        let seq_o = self.seq_obs.load(Ordering::Relaxed);
537        conn.execute(
538            "UPDATE graph_stat SET value = CASE key WHEN 'entity_seq' THEN ?1 WHEN 'obs_seq' THEN ?2 ELSE value END
539             WHERE key IN ('entity_seq', 'obs_seq')",
540            params![seq_e, seq_o],
541        )
542        .map_err(sqlite_err)?;
543        Ok(())
544    }
545
546    // ── Public API ──────────────────────────────────────────────────────
547
548    pub fn get_entity(&self, name: &str) -> Result<Option<Entity>> {
549        let conn = self.readers.get();
550        // One read transaction prevents mixing metadata and observations from
551        // opposite sides of a concurrent commit.
552        let tx = conn.unchecked_transaction().map_err(sqlite_err)?;
553        let entity = crate::mutation::read_entity(&tx, name)?.map(|snapshot| snapshot.entity());
554        tx.commit().map_err(sqlite_err)?;
555        Ok(entity)
556    }
557
558    fn mutate(&self, request: MutationRequest) -> Result<MutationResult> {
559        MutationService::new(self)
560            .apply_with_result(request, MutationContext::local())
561            .map(|(_, result)| result)
562    }
563
564    pub fn create_entities(&self, entities: &[EntityInput]) -> Result<Vec<Entity>> {
565        match self.mutate(MutationRequest::CreateEntities {
566            entities: entities.to_vec(),
567        })? {
568            MutationResult::Entities(result) => Ok(result),
569            _ => unreachable!("create_entities always returns entities"),
570        }
571    }
572
573    pub fn upsert_entities(&self, entities: &[EntityInput]) -> Result<Vec<Entity>> {
574        match self.mutate(MutationRequest::UpsertEntities {
575            entities: entities.to_vec(),
576        })? {
577            MutationResult::Entities(result) => Ok(result),
578            _ => unreachable!("upsert_entities always returns entities"),
579        }
580    }
581
582    pub fn delete_entities(&self, names: &[String]) -> Result<()> {
583        self.mutate(MutationRequest::DeleteEntities {
584            names: names.to_vec(),
585        })
586        .map(|_| ())
587    }
588
589    pub fn create_relations(&self, relations: &[Relation]) -> Result<Vec<Relation>> {
590        match self.mutate(MutationRequest::CreateRelations {
591            relations: relations.to_vec(),
592        })? {
593            MutationResult::Relations(result) => Ok(result),
594            _ => unreachable!("create_relations always returns relations"),
595        }
596    }
597
598    pub fn delete_relations(&self, relations: &[Relation]) -> Result<()> {
599        self.mutate(MutationRequest::DeleteRelations {
600            relations: relations.to_vec(),
601        })
602        .map(|_| ())
603    }
604
605    pub fn add_observations(
606        &self,
607        entity_name: &str,
608        contents: &[ObservationInput],
609    ) -> Result<Vec<Observation>> {
610        match self.mutate(MutationRequest::AddObservations {
611            observations: vec![ObservationUpdate {
612                entity_name: entity_name.into(),
613                contents: contents.to_vec(),
614            }],
615        })? {
616            MutationResult::Observations(mut result) => Ok(result.remove(0).added_observations),
617            _ => unreachable!("add_observations always returns observations"),
618        }
619    }
620
621    pub fn delete_observations(
622        &self,
623        entity_name: &str,
624        observations: &[ObservationInput],
625    ) -> Result<()> {
626        self.mutate(MutationRequest::DeleteObservations {
627            observations: vec![ObservationUpdate {
628                entity_name: entity_name.into(),
629                contents: observations.to_vec(),
630            }],
631        })
632        .map(|_| ())
633    }
634
635    pub fn merge_entities(&self, source: &str, target: &str) -> Result<Entity> {
636        match self.mutate(MutationRequest::MergeEntities {
637            source: source.into(),
638            target: target.into(),
639        })? {
640            MutationResult::Entity(result) => Ok(result),
641            _ => unreachable!("merge_entities always returns an entity"),
642        }
643    }
644
645    pub fn rename_entity(&self, old_name: &str, new_name: &str) -> Result<Entity> {
646        match self.mutate(MutationRequest::RenameEntity {
647            old_name: old_name.into(),
648            new_name: new_name.into(),
649        })? {
650            MutationResult::Entity(result) => Ok(result),
651            _ => unreachable!("rename_entity always returns an entity"),
652        }
653    }
654
655    /// Delete a code file and all its defined symbols in the same transaction.
656    pub fn code_purge_file(&self, rel_path: &str) -> Result<usize> {
657        match self.mutate(MutationRequest::PurgeDefinedEntities {
658            name: rel_path.into(),
659        })? {
660            MutationResult::Count(count) => Ok(count),
661            _ => unreachable!("purge always returns a count"),
662        }
663    }
664
665    pub fn search_nodes_filtered(
666        &self,
667        query: &str,
668        filter_type: Option<&str>,
669        offset: usize,
670        limit: usize,
671    ) -> Vec<Entity> {
672        if query.is_empty() {
673            return Vec::new();
674        }
675        let conn = self.readers.get();
676
677        // Collect ordered candidate ids, then load them all in ONE query instead
678        // of an `entity_by_id` per candidate (the old N+1). Ordering (name matches
679        // first, then observation matches) and the post-filter offset semantics
680        // are preserved exactly.
681        let cap = offset.saturating_add(limit);
682        let candidates = fts_candidate_ids(&conn, query, cap);
683        let mut by_id = batch_entities_by_ids(&conn, &candidates);
684
685        let mut results = Vec::new();
686        let mut count: usize = 0;
687        for eid in candidates {
688            let Some(entity) = by_id.remove(&eid) else {
689                continue;
690            };
691            if let Some(ft) = filter_type
692                && !ft.is_empty()
693                && entity.entity_type != ft
694            {
695                continue;
696            }
697            if count < offset {
698                count += 1;
699                continue;
700            }
701            if results.len() >= limit {
702                break;
703            }
704            results.push(entity);
705            count += 1;
706        }
707
708        results
709    }
710
711    /// The viewer's search payload: a JSON array of `{name, entityType, obsCount}`
712    /// (no observation bodies — see [`batch_entity_lite_by_ids`]), in the same
713    /// order and with the same post-filter offset semantics as
714    /// [`Self::search_nodes_filtered`]. Returns `(entities_json, returned,
715    /// has_more)`; `has_more` is detected by fetching one extra match past the
716    /// page. Builds the JSON directly to avoid a `Vec<Entity>` round-trip.
717    pub fn search_nodes_lite_json(
718        &self,
719        query: &str,
720        filter_type: Option<&str>,
721        offset: usize,
722        limit: usize,
723    ) -> (String, usize, bool) {
724        use std::fmt::Write as _;
725        if query.is_empty() {
726            return ("[]".to_string(), 0, false);
727        }
728        let conn = self.readers.get();
729        // One extra row past the page lets us report `hasMore` after filtering.
730        let cap = offset.saturating_add(limit).saturating_add(1);
731        let candidates = fts_candidate_ids(&conn, query, cap);
732        let by_id = batch_entity_lite_by_ids(&conn, &candidates);
733        let ft = filter_type.filter(|s| !s.is_empty());
734
735        let mut arr = String::from("[");
736        let mut count: usize = 0; // entities passing the type filter, seen so far
737        let mut returned: usize = 0;
738        let mut has_more = false;
739        for eid in candidates {
740            let Some((name, etype, oc)) = by_id.get(&eid) else {
741                continue;
742            };
743            if let Some(f) = ft
744                && etype != f
745            {
746                continue;
747            }
748            if count < offset {
749                count += 1;
750                continue;
751            }
752            if returned >= limit {
753                has_more = true;
754                break;
755            }
756            if returned > 0 {
757                arr.push(',');
758            }
759            arr.push_str("{\"name\":");
760            push_json_str(&mut arr, name);
761            arr.push_str(",\"entityType\":");
762            push_json_str(&mut arr, etype);
763            let _ = write!(arr, ",\"obsCount\":{oc}}}");
764            returned += 1;
765            count += 1;
766        }
767        arr.push(']');
768        (arr, returned, has_more)
769    }
770
771    pub fn read_graph_filtered(
772        &self,
773        filter_type: Option<&str>,
774        offset: usize,
775        limit: usize,
776    ) -> Result<String> {
777        self.read_graph_page(filter_type, offset, limit, true)
778            .map(|(json, _)| json)
779    }
780
781    /// Observation-free page for the browser viewer: entities carry `obsCount`
782    /// (the denormalised count) instead of the observation bodies, which the
783    /// canvas never renders in bulk — the inspector lazy-loads them for the one
784    /// selected node via `GET /ui/node`. Returns `(json, returned)` so the caller
785    /// can build the pagination cursor without re-parsing the payload.
786    pub fn read_graph_filtered_lite(
787        &self,
788        filter_type: Option<&str>,
789        offset: usize,
790        limit: usize,
791    ) -> Result<(String, usize)> {
792        self.read_graph_page(filter_type, offset, limit, false)
793    }
794
795    fn read_graph_page(
796        &self,
797        filter_type: Option<&str>,
798        offset: usize,
799        limit: usize,
800        include_obs: bool,
801    ) -> Result<(String, usize)> {
802        let conn = self.readers.get();
803
804        let limit_sql: i64 = if limit == usize::MAX {
805            -1
806        } else {
807            limit.min(i64::MAX as usize) as i64
808        };
809        let offset_sql: i64 = offset as i64;
810
811        // Resolve the requested page of entity ids first. Relations are then
812        // scoped to edges whose *both* endpoints fall inside this page, which
813        // keeps the response self-consistent (no dangling references to
814        // entities that were paged out) and bounds the relation payload by the
815        // page size instead of dumping every relation in the graph.
816        let filter = filter_type.filter(|ft| !ft.is_empty());
817        let ids: Vec<i64> = if let Some(ft) = filter {
818            let mut stmt = conn
819                .prepare_cached(
820                    "SELECT e.id FROM entity e
821                     WHERE e.type_id = (SELECT id FROM type_dict WHERE kind = 0 AND name = ?1)
822                       AND e.flags = 0
823                     ORDER BY e.id LIMIT ?2 OFFSET ?3",
824                )
825                .map_err(sqlite_err)?;
826            stmt.query_map(params![ft, limit_sql, offset_sql], |r| r.get::<_, i64>(0))
827                .map_err(sqlite_err)?
828                .filter_map(|r| r.ok())
829                .collect()
830        } else {
831            let mut stmt = conn
832                .prepare_cached(
833                    "SELECT e.id FROM entity e WHERE e.flags = 0
834                     ORDER BY e.id LIMIT ?1 OFFSET ?2",
835                )
836                .map_err(sqlite_err)?;
837            stmt.query_map(params![limit_sql, offset_sql], |r| r.get::<_, i64>(0))
838                .map_err(sqlite_err)?
839                .filter_map(|r| r.ok())
840                .collect()
841        };
842
843        if ids.is_empty() {
844            return Ok((r#"{"entities":[],"relations":[]}"#.to_string(), 0));
845        }
846
847        // Inline the page's ids as SQL integer literals rather than bound `?`
848        // parameters: a full-graph read (limit = usize::MAX) can exceed SQLite's
849        // ~32k variable cap, which would error the query. The ids are DB row ids,
850        // so this is injection-safe. See [`int_csv`].
851        let idlist = int_csv(&ids);
852        let returned = ids.len();
853
854        // The viewer omits observation bodies (they inflate the payload, the
855        // reader-lock hold, and browser memory for a graph the canvas only lays
856        // out); it ships `obsCount` instead. The MCP `read_graph` keeps the full
857        // observations shape.
858        let obs_field = if include_obs {
859            format!("'observations', COALESCE((SELECT json_group_array({OBSERVATION_JSON} ORDER BY o.idx, o.id)
860                        FROM observation o WHERE o.entity_id = e.id), json('[]'))")
861        } else {
862            "'obsCount', e.obs_count".to_owned()
863        };
864
865        let entities_json: String = {
866            let sql = format!(
867                "SELECT COALESCE(json_group_array(json_object(
868                    'name', e.name,
869                    'entityType', t.name,
870                    {obs_field}
871                ) ORDER BY e.id), json('[]'))
872                FROM entity e
873                JOIN type_dict t ON t.id = e.type_id
874                WHERE e.id IN ({idlist}) AND e.flags = 0"
875            );
876            conn.query_row(&sql, [], |row| row.get::<_, String>(0))
877                .map_err(sqlite_err)?
878        };
879
880        let relations_json: String = {
881            let sql = format!(
882                "SELECT COALESCE(json_group_array(json_object(
883                    'from', e1.name,
884                    'to', e2.name,
885                    'relationType', t.name
886                )), json('[]'))
887                FROM relation r
888                JOIN entity e1 ON e1.id = r.from_id
889                JOIN entity e2 ON e2.id = r.to_id
890                JOIN type_dict t ON t.id = r.type_id
891                WHERE r.from_id IN ({idlist}) AND r.to_id IN ({idlist})
892                  AND e1.flags = 0 AND e2.flags = 0"
893            );
894            conn.query_row(&sql, [], |row| row.get::<_, String>(0))
895                .map_err(sqlite_err)?
896        };
897
898        let mut out = String::with_capacity(32 + entities_json.len() + relations_json.len());
899        out.push_str("{\"entities\":");
900        out.push_str(&entities_json);
901        out.push_str(",\"relations\":");
902        out.push_str(&relations_json);
903        out.push('}');
904        Ok((out, returned))
905    }
906
907    pub fn open_nodes(&self, names: &[String]) -> String {
908        let conn = self.readers.get();
909        let mut entity_ids: Vec<i64> = Vec::new();
910
911        for name in names {
912            let h = name_hash(name);
913            if let Ok(Some(id)) = conn
914                .query_row(
915                    "SELECT id FROM entity WHERE name_hash = ?1 AND name = ?2 AND flags = 0",
916                    params![h, name],
917                    |row| row.get::<_, i64>(0),
918                )
919                .map(Some)
920                .or_else(|e| {
921                    if is_not_found(&e) {
922                        Ok(None)
923                    } else {
924                        Err(sqlite_err(e))
925                    }
926                })
927            {
928                entity_ids.push(id);
929            }
930        }
931
932        if entity_ids.is_empty() {
933            return r#"{"entities":[],"relations":[]}"#.to_string();
934        }
935
936        let placeholders: Vec<String> = entity_ids.iter().map(|_| "?".to_string()).collect();
937        let ids_str = placeholders.join(",");
938
939        let entities_json: String = {
940            let sql = format!(
941                "SELECT COALESCE(json_group_array(json_object(
942                    'name', e.name,
943                    'entityType', t.name,
944                    'observations', COALESCE((
945                        SELECT json_group_array({OBSERVATION_JSON} ORDER BY o.idx, o.id)
946                        FROM observation o WHERE o.entity_id = e.id
947                    ), json('[]'))
948                ) ORDER BY e.id), json('[]'))
949                FROM entity e
950                JOIN type_dict t ON t.id = e.type_id
951                WHERE e.id IN ({ids_str}) AND e.flags = 0"
952            );
953            conn.query_row(&sql, rusqlite::params_from_iter(&entity_ids), |row| {
954                row.get::<_, String>(0)
955            })
956            .unwrap_or_else(|_| "[]".to_string())
957        };
958
959        let relations_json: String = {
960            let sql = format!(
961                "SELECT COALESCE(json_group_array(json_object(
962                    'from', e1.name,
963                    'to', e2.name,
964                    'relationType', t.name
965                )), json('[]'))
966                FROM relation r
967                JOIN entity e1 ON e1.id = r.from_id
968                JOIN entity e2 ON e2.id = r.to_id
969                JOIN type_dict t ON t.id = r.type_id
970                WHERE (r.from_id IN ({ids_str}) OR r.to_id IN ({ids_str}))
971                  AND e1.flags = 0 AND e2.flags = 0"
972            );
973            let all_params: Vec<&dyn rusqlite::types::ToSql> = entity_ids
974                .iter()
975                .map(|id| id as &dyn rusqlite::types::ToSql)
976                .chain(
977                    entity_ids
978                        .iter()
979                        .map(|id| id as &dyn rusqlite::types::ToSql),
980                )
981                .collect();
982            let mut stmt = conn.prepare(&sql).unwrap();
983            stmt.query_row(all_params.as_slice(), |row| row.get::<_, String>(0))
984                .unwrap_or_else(|_| "[]".to_string())
985        };
986
987        let mut out = String::with_capacity(32 + entities_json.len() + relations_json.len());
988        out.push_str("{\"entities\":");
989        out.push_str(&entities_json);
990        out.push_str(",\"relations\":");
991        out.push_str(&relations_json);
992        out.push('}');
993        out
994    }
995
996    pub fn entities_exist(&self, names: &[String]) -> Result<Vec<bool>> {
997        let conn = self.readers.get();
998        let mut results = Vec::with_capacity(names.len());
999        for name in names {
1000            let h = name_hash(name);
1001            let exists: bool = conn
1002                .query_row(
1003                    "SELECT 1 FROM entity WHERE name_hash = ?1 AND name = ?2 AND flags = 0",
1004                    params![h, name],
1005                    |_| Ok(()),
1006                )
1007                .is_ok();
1008            results.push(exists);
1009        }
1010        Ok(results)
1011    }
1012
1013    pub fn degree(&self, name: &str, direction: Direction) -> Result<usize> {
1014        let conn = self.readers.get();
1015        let (_, _, out_d, in_d) = match self.get_entity_id(&conn, name)? {
1016            Some(v) => v,
1017            None => {
1018                return Err(MCSError::InvalidParams(format!(
1019                    "Entity '{name}' not found"
1020                )));
1021            }
1022        };
1023        Ok(match direction {
1024            Direction::Outgoing => out_d as usize,
1025            Direction::Incoming => in_d as usize,
1026            Direction::Both => (out_d + in_d) as usize,
1027        })
1028    }
1029
1030    pub fn get_entity_count(&self) -> Result<usize> {
1031        let conn = self.readers.get();
1032        read_graph_stat(&conn, "entities")
1033            .map(|v| v as usize)
1034            .map_err(|_| MCSError::MemoryError("Failed to read entity count".into()))
1035    }
1036
1037    pub fn get_relation_count(&self) -> Result<usize> {
1038        let conn = self.readers.get();
1039        read_graph_stat(&conn, "relations")
1040            .map(|v| v as usize)
1041            .map_err(|_| MCSError::MemoryError("Failed to read relation count".into()))
1042    }
1043
1044    pub fn search_relations(
1045        &self,
1046        from: Option<&str>,
1047        to: Option<&str>,
1048        rtype: Option<&str>,
1049        limit: Option<usize>,
1050    ) -> Vec<Relation> {
1051        let conn = self.readers.get();
1052        let mut results = Vec::new();
1053
1054        // A filter that is supplied but resolves to nothing uses the sentinel
1055        // id -1 (which matches no row), so the query returns empty rather than
1056        // silently dropping the filter and matching every relation. The lookups
1057        // are read-only — `get_type_id` would *insert* a phantom type, which is
1058        // both wrong and impossible on a `query_only` reader connection.
1059        let from_id = from
1060            .filter(|f| !f.is_empty())
1061            .map(|f| entity_name_lookup(&conn, f).ok().flatten().unwrap_or(-1));
1062        let to_id = to
1063            .filter(|t| !t.is_empty())
1064            .map(|t| entity_name_lookup(&conn, t).ok().flatten().unwrap_or(-1));
1065        let type_id = rtype
1066            .filter(|rt| !rt.is_empty())
1067            .map(|rt| lookup_type_id(&conn, rt, 1).unwrap_or(-1));
1068
1069        match (from_id, to_id, type_id) {
1070            (Some(fid), Some(tid), Some(tpid)) => {
1071                if let Ok(mut stmt) = conn.prepare_cached(
1072                    "SELECT e1.name, e2.name, t.name
1073                     FROM relation r
1074                     JOIN entity e1 ON e1.id = r.from_id
1075                     JOIN entity e2 ON e2.id = r.to_id
1076                     JOIN type_dict t ON t.id = r.type_id
1077                     WHERE r.from_id = ?1 AND r.to_id = ?2 AND r.type_id = ?3
1078                       AND e1.flags = 0 AND e2.flags = 0
1079                     ORDER BY r.from_id, r.to_id",
1080                ) && let Ok(rows) = stmt.query_map(params![fid, tid, tpid], |row| {
1081                    Ok(Relation {
1082                        from: row.get(0)?,
1083                        to: row.get(1)?,
1084                        relation_type: row.get(2)?,
1085                    })
1086                }) {
1087                    for row in rows.flatten() {
1088                        results.push(row);
1089                    }
1090                }
1091            }
1092            (Some(fid), Some(tid), None) => {
1093                if let Ok(mut stmt) = conn.prepare_cached(
1094                    "SELECT e1.name, e2.name, t.name
1095                     FROM relation r
1096                     JOIN entity e1 ON e1.id = r.from_id
1097                     JOIN entity e2 ON e2.id = r.to_id
1098                     JOIN type_dict t ON t.id = r.type_id
1099                     WHERE r.from_id = ?1 AND r.to_id = ?2
1100                       AND e1.flags = 0 AND e2.flags = 0
1101                     ORDER BY r.from_id, r.to_id",
1102                ) && let Ok(rows) = stmt.query_map(params![fid, tid], |row| {
1103                    Ok(Relation {
1104                        from: row.get(0)?,
1105                        to: row.get(1)?,
1106                        relation_type: row.get(2)?,
1107                    })
1108                }) {
1109                    for row in rows.flatten() {
1110                        results.push(row);
1111                    }
1112                }
1113            }
1114            (Some(fid), None, Some(tpid)) => {
1115                if let Ok(mut stmt) = conn.prepare_cached(
1116                    "SELECT e1.name, e2.name, t.name
1117                     FROM relation r
1118                     JOIN entity e1 ON e1.id = r.from_id
1119                     JOIN entity e2 ON e2.id = r.to_id
1120                     JOIN type_dict t ON t.id = r.type_id
1121                     WHERE r.from_id = ?1 AND r.type_id = ?2
1122                       AND e1.flags = 0 AND e2.flags = 0
1123                     ORDER BY r.from_id, r.to_id",
1124                ) && let Ok(rows) = stmt.query_map(params![fid, tpid], |row| {
1125                    Ok(Relation {
1126                        from: row.get(0)?,
1127                        to: row.get(1)?,
1128                        relation_type: row.get(2)?,
1129                    })
1130                }) {
1131                    for row in rows.flatten() {
1132                        results.push(row);
1133                    }
1134                }
1135            }
1136            (None, Some(tid), Some(tpid)) => {
1137                if let Ok(mut stmt) = conn.prepare_cached(
1138                    "SELECT e1.name, e2.name, t.name
1139                     FROM relation r
1140                     JOIN entity e1 ON e1.id = r.from_id
1141                     JOIN entity e2 ON e2.id = r.to_id
1142                     JOIN type_dict t ON t.id = r.type_id
1143                     WHERE r.to_id = ?1 AND r.type_id = ?2
1144                       AND e1.flags = 0 AND e2.flags = 0
1145                     ORDER BY r.from_id, r.to_id",
1146                ) && let Ok(rows) = stmt.query_map(params![tid, tpid], |row| {
1147                    Ok(Relation {
1148                        from: row.get(0)?,
1149                        to: row.get(1)?,
1150                        relation_type: row.get(2)?,
1151                    })
1152                }) {
1153                    for row in rows.flatten() {
1154                        results.push(row);
1155                    }
1156                }
1157            }
1158            (Some(fid), None, None) => {
1159                if let Ok(mut stmt) = conn.prepare_cached(
1160                    "SELECT e1.name, e2.name, t.name
1161                     FROM relation r
1162                     JOIN entity e1 ON e1.id = r.from_id
1163                     JOIN entity e2 ON e2.id = r.to_id
1164                     JOIN type_dict t ON t.id = r.type_id
1165                     WHERE r.from_id = ?1
1166                       AND e1.flags = 0 AND e2.flags = 0
1167                     ORDER BY r.from_id, r.to_id",
1168                ) && let Ok(rows) = stmt.query_map(params![fid], |row| {
1169                    Ok(Relation {
1170                        from: row.get(0)?,
1171                        to: row.get(1)?,
1172                        relation_type: row.get(2)?,
1173                    })
1174                }) {
1175                    for row in rows.flatten() {
1176                        results.push(row);
1177                    }
1178                }
1179            }
1180            (None, Some(tid), None) => {
1181                if let Ok(mut stmt) = conn.prepare_cached(
1182                    "SELECT e1.name, e2.name, t.name
1183                     FROM relation r
1184                     JOIN entity e1 ON e1.id = r.from_id
1185                     JOIN entity e2 ON e2.id = r.to_id
1186                     JOIN type_dict t ON t.id = r.type_id
1187                     WHERE r.to_id = ?1
1188                       AND e1.flags = 0 AND e2.flags = 0
1189                     ORDER BY r.from_id, r.to_id",
1190                ) && let Ok(rows) = stmt.query_map(params![tid], |row| {
1191                    Ok(Relation {
1192                        from: row.get(0)?,
1193                        to: row.get(1)?,
1194                        relation_type: row.get(2)?,
1195                    })
1196                }) {
1197                    for row in rows.flatten() {
1198                        results.push(row);
1199                    }
1200                }
1201            }
1202            (None, None, Some(tpid)) => {
1203                if let Ok(mut stmt) = conn.prepare_cached(
1204                    "SELECT e1.name, e2.name, t.name
1205                     FROM relation r
1206                     JOIN entity e1 ON e1.id = r.from_id
1207                     JOIN entity e2 ON e2.id = r.to_id
1208                     JOIN type_dict t ON t.id = r.type_id
1209                     WHERE r.type_id = ?1
1210                       AND e1.flags = 0 AND e2.flags = 0
1211                     ORDER BY r.from_id, r.to_id",
1212                ) && let Ok(rows) = stmt.query_map(params![tpid], |row| {
1213                    Ok(Relation {
1214                        from: row.get(0)?,
1215                        to: row.get(1)?,
1216                        relation_type: row.get(2)?,
1217                    })
1218                }) {
1219                    for row in rows.flatten() {
1220                        results.push(row);
1221                    }
1222                }
1223            }
1224            (None, None, None) => {
1225                if let Ok(mut stmt) = conn.prepare_cached(
1226                    "SELECT e1.name, e2.name, t.name
1227                     FROM relation r
1228                     JOIN entity e1 ON e1.id = r.from_id
1229                     JOIN entity e2 ON e2.id = r.to_id
1230                     JOIN type_dict t ON t.id = r.type_id
1231                     WHERE e1.flags = 0 AND e2.flags = 0
1232                     ORDER BY r.from_id, r.to_id",
1233                ) && let Ok(rows) = stmt.query_map([], |row| {
1234                    Ok(Relation {
1235                        from: row.get(0)?,
1236                        to: row.get(1)?,
1237                        relation_type: row.get(2)?,
1238                    })
1239                }) {
1240                    for row in rows.flatten() {
1241                        results.push(row);
1242                    }
1243                }
1244            }
1245        }
1246        if let Some(lim) = limit {
1247            results.truncate(lim);
1248        }
1249        results
1250    }
1251
1252    pub fn find_path(&self, from: &str, to: &str) -> Result<Option<Vec<String>>> {
1253        let conn = self.readers.get();
1254        let (from_id, _, _, _) = match self.get_entity_id(&conn, from)? {
1255            Some(v) => v,
1256            None => {
1257                return Err(MCSError::InvalidParams(format!(
1258                    "Source entity '{from}' not found"
1259                )));
1260            }
1261        };
1262        let (to_id, _, _, _) = match self.get_entity_id(&conn, to)? {
1263            Some(v) => v,
1264            None => {
1265                return Err(MCSError::InvalidParams(format!(
1266                    "Target entity '{to}' not found"
1267                )));
1268            }
1269        };
1270
1271        if from_id == to_id {
1272            return Ok(Some(vec![from.to_string()]));
1273        }
1274
1275        // BFS with adjacency from relation table.
1276        let mut visited = HashSet::new();
1277        let mut parent: FxHashMap<i64, i64> = FxHashMap::default();
1278        let mut queue = VecDeque::new();
1279        visited.insert(from_id);
1280        queue.push_back(from_id);
1281
1282        while let Some(cur) = queue.pop_front() {
1283            if cur == to_id {
1284                break;
1285            }
1286            // Fetch out-neighbors.
1287            if let Ok(mut stmt) =
1288                conn.prepare_cached("SELECT to_id FROM relation WHERE from_id = ?1")
1289                && let Ok(rows) = stmt.query_map(params![cur], |row| row.get::<_, i64>(0))
1290            {
1291                for row in rows.flatten() {
1292                    if visited.insert(row) {
1293                        parent.insert(row, cur);
1294                        queue.push_back(row);
1295                    }
1296                }
1297            }
1298            // Also check in-neighbors (undirected traversal).
1299            if let Ok(mut stmt) =
1300                conn.prepare_cached("SELECT from_id FROM relation WHERE to_id = ?1")
1301                && let Ok(rows) = stmt.query_map(params![cur], |row| row.get::<_, i64>(0))
1302            {
1303                for row in rows.flatten() {
1304                    if visited.insert(row) {
1305                        parent.insert(row, cur);
1306                        queue.push_back(row);
1307                    }
1308                }
1309            }
1310        }
1311
1312        if !parent.contains_key(&to_id) && to_id != from_id {
1313            return Ok(None);
1314        }
1315
1316        let mut path = Vec::new();
1317        let mut cur = to_id;
1318        path.push(cur);
1319        while let Some(&p) = parent.get(&cur) {
1320            path.push(p);
1321            cur = p;
1322            if cur == from_id {
1323                break;
1324            }
1325        }
1326        path.reverse();
1327
1328        let placeholders: Vec<String> = path.iter().map(|_| "?".to_string()).collect();
1329        let sql = format!(
1330            "SELECT id, name FROM entity WHERE id IN ({})",
1331            placeholders.join(",")
1332        );
1333        let name_map: FxHashMap<i64, String> = if let Ok(mut stmt) = conn.prepare(&sql)
1334            && let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(&path), |row| {
1335                Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1336            }) {
1337            rows.flatten().collect()
1338        } else {
1339            FxHashMap::default()
1340        };
1341
1342        let name_path: Vec<String> = path
1343            .iter()
1344            .filter_map(|id| name_map.get(id).cloned())
1345            .collect();
1346
1347        Ok(Some(name_path))
1348    }
1349
1350    pub fn compact(&self) -> Result<()> {
1351        self.mutate(MutationRequest::Compact).map(|_| ())
1352    }
1353
1354    pub fn neighbors(
1355        &self,
1356        name: &str,
1357        direction: Direction,
1358        rtype: Option<&str>,
1359        depth: u32,
1360    ) -> Result<String> {
1361        self._traverse(name, direction, rtype, depth, true)
1362    }
1363
1364    pub fn extract_subgraph(&self, names: &[String], depth: u32) -> Result<String> {
1365        if names.is_empty() {
1366            return Ok(r#"{"entities":[],"relations":[]}"#.to_string());
1367        }
1368
1369        let conn = self.readers.get();
1370        let mut all_entity_ids: HashSet<i64> = HashSet::new();
1371        let mut frontier: HashSet<i64> = HashSet::new();
1372        let mut all_rel_pairs: HashSet<(i64, i64, i64)> = HashSet::new();
1373
1374        // Resolve seed entities.
1375        for name in names {
1376            let h = name_hash(name);
1377            if let Ok(Some(id)) = conn
1378                .query_row(
1379                    "SELECT id FROM entity WHERE name_hash = ?1 AND name = ?2 AND flags = 0",
1380                    params![h, name],
1381                    |row| row.get::<_, i64>(0),
1382                )
1383                .map(Some)
1384                .or_else(|e| {
1385                    if is_not_found(&e) {
1386                        Ok(None)
1387                    } else {
1388                        Err(sqlite_err(e))
1389                    }
1390                })
1391            {
1392                all_entity_ids.insert(id);
1393                frontier.insert(id);
1394            }
1395        }
1396
1397        let mut current_depth = 0u32;
1398        while current_depth < depth && !frontier.is_empty() {
1399            let mut next_frontier: HashSet<i64> = HashSet::new();
1400
1401            // Collect relations for current frontier — batched IN queries
1402            // instead of one query per frontier entity.
1403            const CHUNK: usize = 500;
1404            let frontier_ids: Vec<i64> = frontier.iter().copied().collect();
1405            for chunk in frontier_ids.chunks(CHUNK) {
1406                let placeholders: Vec<String> = chunk.iter().map(|_| "?".to_string()).collect();
1407                let in_clause = placeholders.join(",");
1408
1409                // Forward: from_id IN chunk.
1410                if let Ok(mut stmt) = conn.prepare(&format!(
1411                    "SELECT from_id, to_id, type_id FROM relation WHERE from_id IN ({in_clause})",
1412                )) && let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(chunk), |row| {
1413                    Ok((
1414                        row.get::<_, i64>(0)?,
1415                        row.get::<_, i64>(1)?,
1416                        row.get::<_, i64>(2)?,
1417                    ))
1418                }) {
1419                    for row in rows.flatten() {
1420                        let (from_id, to_id, type_id) = row;
1421                        all_rel_pairs.insert((from_id, to_id, type_id));
1422                        if all_entity_ids.insert(to_id) {
1423                            next_frontier.insert(to_id);
1424                        }
1425                    }
1426                }
1427
1428                // Backward: to_id IN chunk.
1429                if let Ok(mut stmt) = conn.prepare(&format!(
1430                    "SELECT from_id, to_id, type_id FROM relation WHERE to_id IN ({in_clause})",
1431                )) && let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(chunk), |row| {
1432                    Ok((
1433                        row.get::<_, i64>(0)?,
1434                        row.get::<_, i64>(1)?,
1435                        row.get::<_, i64>(2)?,
1436                    ))
1437                }) {
1438                    for row in rows.flatten() {
1439                        let (from_id, to_id, type_id) = row;
1440                        all_rel_pairs.insert((from_id, to_id, type_id));
1441                        if all_entity_ids.insert(from_id) {
1442                            next_frontier.insert(from_id);
1443                        }
1444                    }
1445                }
1446            }
1447            if all_entity_ids.len() > MAX_TRAVERSAL_ENTITIES
1448                || all_rel_pairs.len() > MAX_TRAVERSAL_RELS
1449            {
1450                break;
1451            }
1452            frontier = next_frontier;
1453            current_depth += 1;
1454        }
1455
1456        let entities_json: String = if all_entity_ids.is_empty() {
1457            "[]".to_string()
1458        } else {
1459            let ids: Vec<i64> = all_entity_ids.iter().copied().collect();
1460            let sql = format!(
1461                "SELECT COALESCE(json_group_array(json_object(
1462                    'name', e.name,
1463                    'entityType', t.name,
1464                    'observations', COALESCE((
1465                        SELECT json_group_array({OBSERVATION_JSON} ORDER BY o.idx, o.id)
1466                        FROM observation o WHERE o.entity_id = e.id
1467                    ), json('[]'))
1468                ) ORDER BY e.id), json('[]'))
1469                FROM entity e
1470                JOIN type_dict t ON t.id = e.type_id
1471                WHERE e.id IN ({}) AND e.flags = 0",
1472                int_csv(&ids)
1473            );
1474            conn.query_row(&sql, [], |row| row.get::<_, String>(0))
1475                .map_err(sqlite_err)?
1476        };
1477
1478        let relations_json: String = if all_rel_pairs.is_empty() {
1479            "[]".to_string()
1480        } else {
1481            let sql = format!(
1482                "WITH r(from_id, to_id, type_id) AS (VALUES {})
1483                SELECT COALESCE(json_group_array(json_object(
1484                    'from', e1.name,
1485                    'to', e2.name,
1486                    'relationType', t.name
1487                )), json('[]'))
1488                FROM r
1489                JOIN entity e1 ON e1.id = r.from_id
1490                JOIN entity e2 ON e2.id = r.to_id
1491                JOIN type_dict t ON t.id = r.type_id
1492                WHERE e1.flags = 0 AND e2.flags = 0",
1493                rel_values_literal(&all_rel_pairs)
1494            );
1495            conn.query_row(&sql, [], |row| row.get::<_, String>(0))
1496                .map_err(sqlite_err)?
1497        };
1498
1499        let mut out = String::with_capacity(32 + entities_json.len() + relations_json.len());
1500        out.push_str("{\"entities\":");
1501        out.push_str(&entities_json);
1502        out.push_str(",\"relations\":");
1503        out.push_str(&relations_json);
1504        out.push('}');
1505        Ok(out)
1506    }
1507
1508    pub fn describe_entity(&self, name: &str) -> Result<EntityDescription> {
1509        let conn = self.readers.get();
1510        // Keep the entity, its observations, incident relations, and degree in
1511        // one WAL snapshot so a concurrent graph mutation cannot split this
1512        // public read model across commits.
1513        let tx = conn.unchecked_transaction().map_err(sqlite_err)?;
1514        let entity = crate::mutation::read_entity(&tx, name)?
1515            .ok_or_else(|| MCSError::InvalidParams(format!("Entity '{name}' not found")))?;
1516        let relations = crate::mutation::relations_for(&tx, name)?;
1517        let mut neighbors: Vec<String> = relations
1518            .iter()
1519            .map(|relation| {
1520                if relation.from == name {
1521                    relation.to.clone()
1522                } else {
1523                    relation.from.clone()
1524                }
1525            })
1526            .collect();
1527        neighbors.sort();
1528        neighbors.dedup();
1529        // The counters on `entity` are a denormalized cache. Derive the public
1530        // degree from this response's incident relations so legacy counter
1531        // drift cannot make one snapshot internally inconsistent.
1532        let incoming = relations
1533            .iter()
1534            .filter(|relation| relation.to == name)
1535            .count() as i64;
1536        let outgoing = relations
1537            .iter()
1538            .filter(|relation| relation.from == name)
1539            .count() as i64;
1540        tx.commit().map_err(sqlite_err)?;
1541
1542        Ok(EntityDescription {
1543            name: entity.name,
1544            entity_type: entity.entity_type,
1545            observations: entity.observations,
1546            relations,
1547            neighbors,
1548            degree: Degree { incoming, outgoing },
1549        })
1550    }
1551
1552    pub fn entity_type_counts(&self) -> Vec<(String, usize)> {
1553        let conn = self.readers.get();
1554        select_all_types(&conn, 0).unwrap_or_default()
1555    }
1556
1557    /// `(name, count, desc)` for every registered entity type: one with
1558    /// members, or one whose description registers it before first use.
1559    pub fn entity_type_catalog(&self) -> Vec<(String, usize, Option<String>)> {
1560        let conn = self.readers.get();
1561        select_type_catalog(&conn, 0).unwrap_or_default()
1562    }
1563
1564    /// The viewer's shared page metadata — entity-type legend and the graph-wide
1565    /// entity/relation totals — gathered on a *single* reader connection. The
1566    /// `/ui/graph` and `/ui/search` handlers used to take three or four separate
1567    /// reader-pool acquisitions per request (`entity_type_counts` +
1568    /// `get_entity_count` + `get_relation_count`); folding them into one lock
1569    /// acquisition cuts pool churn and shortens the reader hold, which is what
1570    /// bounds concurrent read throughput.
1571    pub fn ui_meta(&self) -> (Vec<(String, usize)>, usize, usize) {
1572        let conn = self.readers.get();
1573        let types = select_all_types(&conn, 0).unwrap_or_default();
1574        let entities = read_graph_stat(&conn, "entities").unwrap_or(0).max(0) as usize;
1575        let relations = read_graph_stat(&conn, "relations").unwrap_or(0).max(0) as usize;
1576        (types, entities, relations)
1577    }
1578
1579    pub fn relation_type_counts(&self) -> Vec<(String, usize)> {
1580        let conn = self.readers.get();
1581        select_all_types(&conn, 1).unwrap_or_default()
1582    }
1583
1584    /// `(name, count, desc)` for every registered relation type: one with
1585    /// members, or one whose description registers it before first use.
1586    pub fn relation_type_catalog(&self) -> Vec<(String, usize, Option<String>)> {
1587        let conn = self.readers.get();
1588        select_type_catalog(&conn, 1).unwrap_or_default()
1589    }
1590
1591    /// Set or clear the description of one entity type (`kind` 0) or relation
1592    /// type (`kind` 1) by exact name. The type row is created when it does
1593    /// not exist yet — a description can thus register a type before its
1594    /// first member (`count` stays 0). `None` clears the stored description.
1595    /// This is registry metadata, not a graph mutation: no change event is
1596    /// emitted and the taxonomy index revision is untouched.
1597    pub fn set_type_description(
1598        &self,
1599        kind: i64,
1600        name: &str,
1601        description: Option<&str>,
1602    ) -> Result<()> {
1603        let conn = self.writer.lock();
1604        // The writer lock serializes, so select-then-insert is race-free —
1605        // the same pattern `mutation::type_id` uses for the insert path.
1606        let existing = lookup_type_id(&conn, name, kind);
1607        let description: Option<String> = description.map(|d| d.into());
1608        match existing {
1609            Some(id) => {
1610                conn.execute(
1611                    "UPDATE type_dict SET desc = ?1 WHERE id = ?2",
1612                    params![description, id],
1613                )
1614                .map_err(sqlite_err)?;
1615            }
1616            None => {
1617                conn.execute(
1618                    "INSERT INTO type_dict(kind, name, count, desc) VALUES(?1, ?2, 0, ?3)",
1619                    params![kind, name, description],
1620                )
1621                .map_err(sqlite_err)?;
1622            }
1623        }
1624        Ok(())
1625    }
1626
1627    /// Whether an entity type with the given name exists. Read-only: a missing
1628    /// type stays absent and no row is inserted.
1629    pub fn entity_type_exists(&self, name: &str) -> bool {
1630        let conn = self.readers.get();
1631        lookup_type_id(&conn, name, 0).is_some()
1632    }
1633
1634    /// Whether a relation type with the given name exists. Read-only: a missing
1635    /// type stays absent and no row is inserted.
1636    pub fn relation_type_exists(&self, name: &str) -> bool {
1637        let conn = self.readers.get();
1638        lookup_type_id(&conn, name, 1).is_some()
1639    }
1640
1641    pub fn batch_get_entities(&self, names: &[String]) -> Vec<Option<Entity>> {
1642        names
1643            .iter()
1644            .map(|n| self.get_entity(n).unwrap_or(None))
1645            .collect()
1646    }
1647
1648    pub fn find_all_paths(
1649        &self,
1650        from: &str,
1651        to: &str,
1652        max_depth: usize,
1653        max_paths: usize,
1654    ) -> Result<Vec<Vec<String>>> {
1655        let conn = self.readers.get();
1656        let (from_id, _, _, _) = match self.get_entity_id(&conn, from)? {
1657            Some(v) => v,
1658            None => {
1659                return Err(MCSError::InvalidParams(format!(
1660                    "Source entity '{from}' not found"
1661                )));
1662            }
1663        };
1664        let (to_id, _, _, _) = match self.get_entity_id(&conn, to)? {
1665            Some(v) => v,
1666            None => {
1667                return Err(MCSError::InvalidParams(format!(
1668                    "Target entity '{to}' not found"
1669                )));
1670            }
1671        };
1672
1673        if from_id == to_id {
1674            return Ok(vec![vec![from.to_string()]]);
1675        }
1676
1677        // BFS enumerating all paths up to max_depth.
1678        let mut all_paths: Vec<Vec<i64>> = Vec::new();
1679        let mut queue: VecDeque<(i64, Vec<i64>)> = VecDeque::new();
1680        queue.push_back((from_id, vec![from_id]));
1681
1682        const MAX_QUEUE_SIZE: usize = 10_000_000;
1683
1684        while let Some((cur, path)) = queue.pop_front() {
1685            if all_paths.len() >= max_paths {
1686                break;
1687            }
1688            if path.len() > max_depth {
1689                continue;
1690            }
1691
1692            // Out-neighbors.
1693            if let Ok(mut stmt) =
1694                conn.prepare_cached("SELECT to_id FROM relation WHERE from_id = ?1")
1695                && let Ok(rows) = stmt.query_map(params![cur], |row| row.get::<_, i64>(0))
1696            {
1697                for next_id in rows.flatten() {
1698                    if next_id == to_id {
1699                        let mut full_path = path.clone();
1700                        full_path.push(next_id);
1701                        all_paths.push(full_path);
1702                        if all_paths.len() >= max_paths {
1703                            break;
1704                        }
1705                    } else if !path.contains(&next_id) && path.len() < max_depth {
1706                        if queue.len() >= MAX_QUEUE_SIZE {
1707                            return Err(MCSError::InvalidParams(
1708                                    "Path exploration queue exceeded limit (too many paths on highly connected graph)".to_string()
1709                                ));
1710                        }
1711                        let mut new_path = path.clone();
1712                        new_path.push(next_id);
1713                        queue.push_back((next_id, new_path));
1714                    }
1715                }
1716            }
1717
1718            // In-neighbors (undirected).
1719            if let Ok(mut stmt) =
1720                conn.prepare_cached("SELECT from_id FROM relation WHERE to_id = ?1")
1721                && let Ok(rows) = stmt.query_map(params![cur], |row| row.get::<_, i64>(0))
1722            {
1723                for next_id in rows.flatten() {
1724                    if next_id == to_id {
1725                        let mut full_path = path.clone();
1726                        full_path.push(next_id);
1727                        all_paths.push(full_path);
1728                        if all_paths.len() >= max_paths {
1729                            break;
1730                        }
1731                    } else if !path.contains(&next_id) && path.len() < max_depth {
1732                        if queue.len() >= MAX_QUEUE_SIZE {
1733                            return Err(MCSError::InvalidParams(
1734                                    "Path exploration queue exceeded limit (too many paths on highly connected graph)".to_string()
1735                                ));
1736                        }
1737                        let mut new_path = path.clone();
1738                        new_path.push(next_id);
1739                        queue.push_back((next_id, new_path));
1740                    }
1741                }
1742            }
1743        }
1744
1745        // Convert ids to names — one batch query instead of N lookups per path.
1746        let all_ids: HashSet<i64> = all_paths.iter().flat_map(|p| p.iter()).copied().collect();
1747        let id_list: Vec<i64> = all_ids.into_iter().collect();
1748        let name_map: FxHashMap<i64, String> = if id_list.is_empty() {
1749            FxHashMap::default()
1750        } else {
1751            let placeholders: Vec<String> = id_list.iter().map(|_| "?".to_string()).collect();
1752            let sql = format!(
1753                "SELECT id, name FROM entity WHERE id IN ({})",
1754                placeholders.join(",")
1755            );
1756            if let Ok(mut stmt) = conn.prepare(&sql)
1757                && let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(&id_list), |row| {
1758                    Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1759                })
1760            {
1761                rows.flatten().collect()
1762            } else {
1763                FxHashMap::default()
1764            }
1765        };
1766
1767        let mut named_paths: Vec<Vec<String>> = Vec::with_capacity(all_paths.len());
1768        for path_ids in all_paths {
1769            let named: Vec<String> = path_ids
1770                .iter()
1771                .filter_map(|id| name_map.get(id).cloned())
1772                .collect();
1773            named_paths.push(named);
1774        }
1775
1776        Ok(named_paths)
1777    }
1778
1779    /// Export the whole graph as a JSON string. `max_rows` caps both the entity
1780    /// and relation arrays so a pathologically large graph cannot be coerced
1781    /// into an unbounded in-memory string (DoS guard); callers pass a generous
1782    /// constant. A negative value means "no limit".
1783    pub fn export(&self, _format: &str, max_rows: i64) -> Result<String> {
1784        let conn = self.readers.get();
1785        // Only JSON is supported; the format argument is accepted for forward
1786        // compatibility.
1787        conn.query_row(
1788            &format!(
1789                "SELECT json_object(
1790                'entities', COALESCE((
1791                    SELECT json_group_array(json_object(
1792                        'name', e.name,
1793                        'entityType', t.name,
1794                        'observations', COALESCE((
1795                            SELECT json_group_array({OBSERVATION_JSON} ORDER BY o.idx, o.id)
1796                            FROM observation o WHERE o.entity_id = e.id
1797                        ), json('[]'))
1798                    ) ORDER BY e.id)
1799                    FROM (
1800                        SELECT id, name, type_id FROM entity
1801                        WHERE flags = 0 ORDER BY id LIMIT ?1
1802                    ) e
1803                    JOIN type_dict t ON t.id = e.type_id
1804                ), json('[]')),
1805                'relations', COALESCE((
1806                    SELECT json_group_array(json_object(
1807                        'from', e1.name,
1808                        'to', e2.name,
1809                        'relationType', t.name
1810                    ))
1811                    FROM (
1812                        SELECT from_id, to_id, type_id FROM relation LIMIT ?1
1813                    ) r
1814                    JOIN entity e1 ON e1.id = r.from_id
1815                    JOIN entity e2 ON e2.id = r.to_id
1816                    JOIN type_dict t ON t.id = r.type_id
1817                    WHERE e1.flags = 0 AND e2.flags = 0
1818                ), json('[]'))
1819            )"
1820            ),
1821            params![max_rows],
1822            |row| row.get::<_, String>(0),
1823        )
1824        .map_err(sqlite_err)
1825    }
1826
1827    pub fn wipe(&self) -> Result<()> {
1828        self.mutate(MutationRequest::Wipe).map(|_| ())
1829    }
1830
1831    /// Periodic database maintenance: WAL checkpoint, query planner analysis,
1832    /// and FTS index optimization. Call from a background timer.
1833    pub fn run_maintenance(&self) -> Result<()> {
1834        let conn = self.writer.lock();
1835
1836        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
1837            .map_err(sqlite_err)?;
1838
1839        conn.execute_batch("PRAGMA optimize(0x10000);")
1840            .map_err(sqlite_err)?;
1841
1842        let tx = TxGuard::begin(&conn)?;
1843        conn.execute_batch(
1844            "INSERT INTO name_fts(name_fts) VALUES('optimize');
1845             INSERT INTO obs_fts(obs_fts) VALUES('optimize');",
1846        )
1847        .map_err(sqlite_err)?;
1848        tx.commit()?;
1849
1850        Ok(())
1851    }
1852
1853    /// Run a non-blocking `wal_checkpoint(PASSIVE)` to fsync committed WAL frames
1854    /// without stalling readers or writers. Call from a short-interval timer to
1855    /// bound the durability window in `async` mode.
1856    pub fn checkpoint_passive(&self) -> Result<()> {
1857        let conn = self.writer.lock();
1858        conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);")
1859            .map_err(sqlite_err)?;
1860        Ok(())
1861    }
1862
1863    fn _traverse(
1864        &self,
1865        name: &str,
1866        direction: Direction,
1867        rtype: Option<&str>,
1868        depth: u32,
1869        // unused — we always include relations; the caller controls via depth
1870        _include_relations: bool,
1871    ) -> Result<String> {
1872        let conn = self.readers.get();
1873        let (start_id, _, _, _) = match self.get_entity_id(&conn, name)? {
1874            Some(v) => v,
1875            None => {
1876                return Err(MCSError::InvalidParams(format!(
1877                    "Entity '{name}' not found"
1878                )));
1879            }
1880        };
1881
1882        let mut all_ids: HashSet<i64> = HashSet::new();
1883        let mut all_rels: HashSet<(i64, i64, i64)> = HashSet::new();
1884        let mut frontier: HashSet<i64> = HashSet::new();
1885        all_ids.insert(start_id);
1886        frontier.insert(start_id);
1887
1888        // Read-only type resolution. A requested-but-missing type uses the
1889        // sentinel id -1 (matches no edge), so traversal yields just the start
1890        // entity instead of falling back to "no type filter" and walking every
1891        // edge. `get_type_id` is avoided here: it inserts and cannot run on the
1892        // `query_only` reader connection.
1893        let type_filter: Option<i64> = rtype
1894            .filter(|rt| !rt.is_empty())
1895            .map(|rt| lookup_type_id(&conn, rt, 1).unwrap_or(-1));
1896
1897        // Pre-compile all four possible queries outside the loop.
1898        let mut q_out_t = conn.prepare_cached(
1899            "SELECT to_id, type_id FROM relation WHERE from_id = ?1 AND type_id = ?2",
1900        );
1901        let mut q_out =
1902            conn.prepare_cached("SELECT to_id, type_id FROM relation WHERE from_id = ?1");
1903        let mut q_in_t = conn.prepare_cached(
1904            "SELECT from_id, type_id FROM relation WHERE to_id = ?1 AND type_id = ?2",
1905        );
1906        let mut q_in =
1907            conn.prepare_cached("SELECT from_id, type_id FROM relation WHERE to_id = ?1");
1908
1909        let mut cur_depth = 0u32;
1910        while cur_depth < depth && !frontier.is_empty() {
1911            let mut next_frontier: HashSet<i64> = HashSet::new();
1912
1913            for &fid in &frontier {
1914                if direction == Direction::Outgoing || direction == Direction::Both {
1915                    if let Some(tid) = type_filter {
1916                        if let Ok(ref mut stmt) = q_out_t
1917                            && let Ok(rows) = stmt.query_map(params![fid, tid], |row| {
1918                                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1919                            })
1920                        {
1921                            for row in rows.flatten() {
1922                                let (to_id, t_id) = row;
1923                                all_rels.insert((fid, to_id, t_id));
1924                                if all_ids.insert(to_id) {
1925                                    next_frontier.insert(to_id);
1926                                }
1927                            }
1928                        }
1929                    } else if let Ok(ref mut stmt) = q_out
1930                        && let Ok(rows) = stmt.query_map(params![fid], |row| {
1931                            Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1932                        })
1933                    {
1934                        for row in rows.flatten() {
1935                            let (to_id, t_id) = row;
1936                            all_rels.insert((fid, to_id, t_id));
1937                            if all_ids.insert(to_id) {
1938                                next_frontier.insert(to_id);
1939                            }
1940                        }
1941                    }
1942                }
1943
1944                if direction == Direction::Incoming || direction == Direction::Both {
1945                    if let Some(tid) = type_filter {
1946                        if let Ok(ref mut stmt) = q_in_t
1947                            && let Ok(rows) = stmt.query_map(params![fid, tid], |row| {
1948                                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1949                            })
1950                        {
1951                            for row in rows.flatten() {
1952                                let (from_id, t_id) = row;
1953                                all_rels.insert((from_id, fid, t_id));
1954                                if all_ids.insert(from_id) {
1955                                    next_frontier.insert(from_id);
1956                                }
1957                            }
1958                        }
1959                    } else if let Ok(ref mut stmt) = q_in
1960                        && let Ok(rows) = stmt.query_map(params![fid], |row| {
1961                            Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
1962                        })
1963                    {
1964                        for row in rows.flatten() {
1965                            let (from_id, t_id) = row;
1966                            all_rels.insert((from_id, fid, t_id));
1967                            if all_ids.insert(from_id) {
1968                                next_frontier.insert(from_id);
1969                            }
1970                        }
1971                    }
1972                }
1973            }
1974
1975            // DoS guard: stop traversal if we've collected too many entities
1976            // or relations. The response will be partial, which is preferable
1977            // to an OOM crash on densely connected graphs.
1978            if all_ids.len() > MAX_TRAVERSAL_ENTITIES || all_rels.len() > MAX_TRAVERSAL_RELS {
1979                break;
1980            }
1981
1982            frontier = next_frontier;
1983            cur_depth += 1;
1984        }
1985
1986        let entities_json: String = if all_ids.is_empty() {
1987            "[]".to_string()
1988        } else {
1989            let ids: Vec<i64> = all_ids.iter().copied().collect();
1990            let sql = format!(
1991                "SELECT COALESCE(json_group_array(json_object(
1992                    'name', e.name,
1993                    'entityType', t.name,
1994                    'observations', COALESCE((
1995                        SELECT json_group_array({OBSERVATION_JSON} ORDER BY o.idx, o.id)
1996                        FROM observation o WHERE o.entity_id = e.id
1997                    ), json('[]'))
1998                ) ORDER BY e.id), json('[]'))
1999                FROM entity e
2000                JOIN type_dict t ON t.id = e.type_id
2001                WHERE e.id IN ({}) AND e.flags = 0",
2002                int_csv(&ids)
2003            );
2004            conn.query_row(&sql, [], |row| row.get::<_, String>(0))
2005                .map_err(sqlite_err)?
2006        };
2007
2008        let relations_json: String = if all_rels.is_empty() {
2009            "[]".to_string()
2010        } else {
2011            let sql = format!(
2012                "WITH r(from_id, to_id, type_id) AS (VALUES {})
2013                SELECT COALESCE(json_group_array(json_object(
2014                    'from', e1.name,
2015                    'to', e2.name,
2016                    'relationType', t.name
2017                )), json('[]'))
2018                FROM r
2019                JOIN entity e1 ON e1.id = r.from_id
2020                JOIN entity e2 ON e2.id = r.to_id
2021                JOIN type_dict t ON t.id = r.type_id
2022                WHERE e1.flags = 0 AND e2.flags = 0",
2023                rel_values_literal(&all_rels)
2024            );
2025            conn.query_row(&sql, [], |row| row.get::<_, String>(0))
2026                .map_err(sqlite_err)?
2027        };
2028
2029        let mut out = String::with_capacity(32 + entities_json.len() + relations_json.len());
2030        out.push_str("{\"entities\":");
2031        out.push_str(&entities_json);
2032        out.push_str(",\"relations\":");
2033        out.push_str(&relations_json);
2034        out.push('}');
2035        Ok(out)
2036    }
2037}
2038
2039// ── Tests ────────────────────────────────────────────────────────────────
2040
2041#[cfg(test)]
2042mod tests {
2043    use super::*;
2044    use crate::types::EntityInput as Entity;
2045    use serde_json::Value;
2046    use std::ops::Deref;
2047    use std::path::PathBuf;
2048
2049    struct TestKg(GraphHandle, PathBuf);
2050
2051    impl Deref for TestKg {
2052        type Target = GraphHandle;
2053        fn deref(&self) -> &GraphHandle {
2054            &self.0
2055        }
2056    }
2057
2058    impl Drop for TestKg {
2059        fn drop(&mut self) {
2060            cleanup_db(&self.1);
2061        }
2062    }
2063
2064    fn cleanup_db(path: &std::path::Path) {
2065        let _ = std::fs::remove_file(path);
2066        let _ = std::fs::remove_file(path.with_extension("db-wal"));
2067        let _ = std::fs::remove_file(path.with_extension("db-shm"));
2068    }
2069
2070    fn new_kg() -> TestKg {
2071        use std::sync::atomic::AtomicU64;
2072        use std::sync::atomic::Ordering;
2073        static COUNTER: AtomicU64 = AtomicU64::new(0);
2074        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
2075        let dir = std::env::temp_dir();
2076        let path = dir.join(format!("kg_test_{}_{}.db", std::process::id(), n));
2077        cleanup_db(&path);
2078        let kg = GraphHandle::new(
2079            &path,
2080            Durability::Async,
2081            SqliteTuning::default(),
2082            NonZeroUsize::new(10000).unwrap(),
2083            4,
2084        )
2085        .expect("create KG");
2086        TestKg(kg, path)
2087    }
2088
2089    #[test]
2090    fn test_create_and_get_entity() {
2091        let kg = new_kg();
2092        let entities = vec![Entity {
2093            name: "test".into(),
2094            entity_type: "person".into(),
2095            observations: vec!["obs1".into(), "obs2".into()],
2096        }];
2097        let created = kg.create_entities(&entities).unwrap();
2098        assert_eq!(created.len(), 1);
2099
2100        let got = kg.get_entity("test").unwrap().unwrap();
2101        assert_eq!(got.name, "test");
2102        assert_eq!(got.entity_type, "person");
2103        assert_eq!(
2104            got.observations
2105                .iter()
2106                .map(|o| o.body.as_str())
2107                .collect::<Vec<_>>(),
2108            vec!["obs1", "obs2"]
2109        );
2110    }
2111
2112    #[test]
2113    fn test_get_nonexistent() {
2114        let kg = new_kg();
2115        assert!(kg.get_entity("nonexistent").unwrap().is_none());
2116    }
2117
2118    #[test]
2119    fn test_delete_entity() {
2120        let kg = new_kg();
2121        kg.create_entities(&[Entity {
2122            name: "del".into(),
2123            entity_type: "t".into(),
2124            observations: vec![],
2125        }])
2126        .unwrap();
2127        assert!(kg.get_entity("del").unwrap().is_some());
2128        kg.delete_entities(&["del".to_string()]).unwrap();
2129        assert!(kg.get_entity("del").unwrap().is_none());
2130    }
2131
2132    #[test]
2133    fn test_add_and_delete_observations() {
2134        let kg = new_kg();
2135        kg.create_entities(&[Entity {
2136            name: "obs_test".into(),
2137            entity_type: "t".into(),
2138            observations: vec!["a".into()],
2139        }])
2140        .unwrap();
2141
2142        let added = kg
2143            .add_observations("obs_test", &["b".into(), "c".into()])
2144            .unwrap();
2145        assert_eq!(added.len(), 2);
2146
2147        let ent = kg.get_entity("obs_test").unwrap().unwrap();
2148        assert!(ent.observations.iter().any(|o| o.body == "b"));
2149        assert!(ent.observations.iter().any(|o| o.body == "c"));
2150
2151        kg.delete_observations("obs_test", &["b".into()]).unwrap();
2152        let ent = kg.get_entity("obs_test").unwrap().unwrap();
2153        assert!(!ent.observations.iter().any(|o| o.body == "b"));
2154        assert!(ent.observations.iter().any(|o| o.body == "c"));
2155        assert!(ent.observations.iter().any(|o| o.body == "a"));
2156    }
2157
2158    #[test]
2159    fn test_create_relations() {
2160        let kg = new_kg();
2161        kg.create_entities(&[
2162            Entity {
2163                name: "A".into(),
2164                entity_type: "node".into(),
2165                observations: vec![],
2166            },
2167            Entity {
2168                name: "B".into(),
2169                entity_type: "node".into(),
2170                observations: vec![],
2171            },
2172        ])
2173        .unwrap();
2174
2175        let rels = kg
2176            .create_relations(&[Relation {
2177                from: "A".into(),
2178                to: "B".into(),
2179                relation_type: "edge".into(),
2180            }])
2181            .unwrap();
2182        assert_eq!(rels.len(), 1);
2183
2184        assert_eq!(kg.get_entity_count().unwrap(), 2);
2185        assert_eq!(kg.get_relation_count().unwrap(), 1);
2186    }
2187
2188    #[test]
2189    fn test_search_nodes() {
2190        let kg = new_kg();
2191        kg.create_entities(&[Entity {
2192            name: "Einstein".into(),
2193            entity_type: "scientist".into(),
2194            observations: vec!["physics".into(), "relativity".into()],
2195        }])
2196        .unwrap();
2197
2198        let results = kg.search_nodes_filtered("physics", None, 0, 10);
2199        assert_eq!(results.len(), 1);
2200        assert_eq!(results[0].name, "Einstein");
2201
2202        let results = kg.search_nodes_filtered("physics", Some("scientist"), 0, 10);
2203        assert_eq!(results.len(), 1);
2204
2205        let results = kg.search_nodes_filtered("physics", Some("nonexistent"), 0, 10);
2206        assert_eq!(results.len(), 0);
2207    }
2208
2209    #[test]
2210    fn test_find_path() {
2211        let kg = new_kg();
2212        kg.create_entities(&[
2213            Entity {
2214                name: "A".into(),
2215                entity_type: "n".into(),
2216                observations: vec![],
2217            },
2218            Entity {
2219                name: "B".into(),
2220                entity_type: "n".into(),
2221                observations: vec![],
2222            },
2223            Entity {
2224                name: "C".into(),
2225                entity_type: "n".into(),
2226                observations: vec![],
2227            },
2228        ])
2229        .unwrap();
2230
2231        kg.create_relations(&[
2232            Relation {
2233                from: "A".into(),
2234                to: "B".into(),
2235                relation_type: "e".into(),
2236            },
2237            Relation {
2238                from: "B".into(),
2239                to: "C".into(),
2240                relation_type: "e".into(),
2241            },
2242        ])
2243        .unwrap();
2244
2245        let path = kg.find_path("A", "C").unwrap().unwrap();
2246        assert_eq!(path, vec!["A", "B", "C"]);
2247    }
2248
2249    #[test]
2250    fn test_degree() {
2251        let kg = new_kg();
2252        kg.create_entities(&[
2253            Entity {
2254                name: "A".into(),
2255                entity_type: "n".into(),
2256                observations: vec![],
2257            },
2258            Entity {
2259                name: "B".into(),
2260                entity_type: "n".into(),
2261                observations: vec![],
2262            },
2263            Entity {
2264                name: "C".into(),
2265                entity_type: "n".into(),
2266                observations: vec![],
2267            },
2268        ])
2269        .unwrap();
2270
2271        kg.create_relations(&[
2272            Relation {
2273                from: "A".into(),
2274                to: "B".into(),
2275                relation_type: "e".into(),
2276            },
2277            Relation {
2278                from: "A".into(),
2279                to: "C".into(),
2280                relation_type: "e".into(),
2281            },
2282        ])
2283        .unwrap();
2284
2285        assert_eq!(kg.degree("A", Direction::Outgoing).unwrap(), 2);
2286        assert_eq!(kg.degree("A", Direction::Incoming).unwrap(), 0);
2287        assert_eq!(kg.degree("B", Direction::Incoming).unwrap(), 1);
2288    }
2289
2290    #[test]
2291    fn test_neighbors() {
2292        let kg = new_kg();
2293        kg.create_entities(&[
2294            Entity {
2295                name: "A".into(),
2296                entity_type: "n".into(),
2297                observations: vec![],
2298            },
2299            Entity {
2300                name: "B".into(),
2301                entity_type: "n".into(),
2302                observations: vec![],
2303            },
2304        ])
2305        .unwrap();
2306
2307        kg.create_relations(&[Relation {
2308            from: "A".into(),
2309            to: "B".into(),
2310            relation_type: "e".into(),
2311        }])
2312        .unwrap();
2313
2314        let result = kg.neighbors("A", Direction::Outgoing, None, 1).unwrap();
2315        let v: Value = serde_json::from_str(&result).unwrap();
2316        assert_eq!(v["entities"].as_array().unwrap().len(), 2);
2317        assert_eq!(v["relations"].as_array().unwrap().len(), 1);
2318    }
2319
2320    #[test]
2321    fn test_open_nodes() {
2322        let kg = new_kg();
2323        kg.create_entities(&[
2324            Entity {
2325                name: "X".into(),
2326                entity_type: "n".into(),
2327                observations: vec!["obs_x".into()],
2328            },
2329            Entity {
2330                name: "Y".into(),
2331                entity_type: "n".into(),
2332                observations: vec!["obs_y".into()],
2333            },
2334        ])
2335        .unwrap();
2336
2337        kg.create_relations(&[Relation {
2338            from: "X".into(),
2339            to: "Y".into(),
2340            relation_type: "e".into(),
2341        }])
2342        .unwrap();
2343
2344        let result = kg.open_nodes(&["X".into()]);
2345        let v: Value = serde_json::from_str(&result).unwrap();
2346        assert_eq!(v["entities"].as_array().unwrap().len(), 1);
2347        assert_eq!(v["relations"].as_array().unwrap().len(), 1);
2348    }
2349
2350    #[test]
2351    fn test_entities_exist() {
2352        let kg = new_kg();
2353        kg.create_entities(&[Entity {
2354            name: "exists".into(),
2355            entity_type: "t".into(),
2356            observations: vec![],
2357        }])
2358        .unwrap();
2359
2360        let res = kg
2361            .entities_exist(&["exists".into(), "missing".into()])
2362            .unwrap();
2363        assert_eq!(res, vec![true, false]);
2364    }
2365
2366    #[test]
2367    fn test_describe_entity() {
2368        let kg = new_kg();
2369        kg.create_entities(&[
2370            Entity {
2371                name: "A".into(),
2372                entity_type: "t".into(),
2373                observations: vec!["o".into()],
2374            },
2375            Entity {
2376                name: "B".into(),
2377                entity_type: "t".into(),
2378                observations: vec![],
2379            },
2380            Entity {
2381                name: "C".into(),
2382                entity_type: "t".into(),
2383                observations: vec![],
2384            },
2385        ])
2386        .unwrap();
2387
2388        kg.create_relations(&[
2389            Relation {
2390                from: "B".into(),
2391                to: "A".into(),
2392                relation_type: "inbound".into(),
2393            },
2394            Relation {
2395                from: "A".into(),
2396                to: "B".into(),
2397                relation_type: "outbound".into(),
2398            },
2399            Relation {
2400                from: "A".into(),
2401                to: "C".into(),
2402                relation_type: "other".into(),
2403            },
2404            Relation {
2405                from: "A".into(),
2406                to: "A".into(),
2407                relation_type: "self".into(),
2408            },
2409        ])
2410        .unwrap();
2411
2412        let entity = kg.describe_entity("A").unwrap();
2413        assert_eq!(entity.name, "A");
2414        assert_eq!(entity.entity_type, "t");
2415        assert_eq!(
2416            entity
2417                .observations
2418                .iter()
2419                .map(|o| o.body.as_str())
2420                .collect::<Vec<_>>(),
2421            ["o"]
2422        );
2423        assert_eq!(entity.relations.len(), 4);
2424        assert_eq!(
2425            entity.relations,
2426            vec![
2427                Relation {
2428                    from: "A".into(),
2429                    to: "A".into(),
2430                    relation_type: "self".into(),
2431                },
2432                Relation {
2433                    from: "A".into(),
2434                    to: "B".into(),
2435                    relation_type: "outbound".into(),
2436                },
2437                Relation {
2438                    from: "A".into(),
2439                    to: "C".into(),
2440                    relation_type: "other".into(),
2441                },
2442                Relation {
2443                    from: "B".into(),
2444                    to: "A".into(),
2445                    relation_type: "inbound".into(),
2446                },
2447            ]
2448        );
2449        assert_eq!(entity.neighbors, ["A", "B", "C"]);
2450        assert_eq!(entity.degree.incoming, 2);
2451        assert_eq!(entity.degree.outgoing, 3);
2452
2453        // `out_deg` and `in_deg` are a denormalized legacy cache. The public
2454        // describe response must describe the returned relation set even when
2455        // a pre-existing database carries stale cache values.
2456        kg.writer
2457            .lock()
2458            .execute(
2459                "UPDATE entity SET out_deg = 99, in_deg = 88 WHERE name = 'A'",
2460                [],
2461            )
2462            .unwrap();
2463        let entity = kg.describe_entity("A").unwrap();
2464        assert_eq!(entity.degree.incoming, 2);
2465        assert_eq!(entity.degree.outgoing, 3);
2466        assert!(kg.describe_entity("missing").is_err());
2467    }
2468
2469    #[test]
2470    fn test_entity_type_counts() {
2471        let kg = new_kg();
2472        kg.create_entities(&[
2473            Entity {
2474                name: "a".into(),
2475                entity_type: "person".into(),
2476                observations: vec![],
2477            },
2478            Entity {
2479                name: "b".into(),
2480                entity_type: "person".into(),
2481                observations: vec![],
2482            },
2483            Entity {
2484                name: "c".into(),
2485                entity_type: "place".into(),
2486                observations: vec![],
2487            },
2488        ])
2489        .unwrap();
2490
2491        let counts = kg.entity_type_counts();
2492        let map: FxHashMap<_, _> = counts.into_iter().collect();
2493        assert_eq!(map.get("person"), Some(&2));
2494        assert_eq!(map.get("place"), Some(&1));
2495    }
2496
2497    #[test]
2498    fn test_relation_type_counts() {
2499        let kg = new_kg();
2500        kg.create_entities(&[
2501            Entity {
2502                name: "a".into(),
2503                entity_type: "n".into(),
2504                observations: vec![],
2505            },
2506            Entity {
2507                name: "b".into(),
2508                entity_type: "n".into(),
2509                observations: vec![],
2510            },
2511            Entity {
2512                name: "c".into(),
2513                entity_type: "n".into(),
2514                observations: vec![],
2515            },
2516        ])
2517        .unwrap();
2518
2519        kg.create_relations(&[
2520            Relation {
2521                from: "a".into(),
2522                to: "b".into(),
2523                relation_type: "knows".into(),
2524            },
2525            Relation {
2526                from: "a".into(),
2527                to: "c".into(),
2528                relation_type: "knows".into(),
2529            },
2530        ])
2531        .unwrap();
2532
2533        let counts = kg.relation_type_counts();
2534        let map: FxHashMap<_, _> = counts.into_iter().collect();
2535        assert_eq!(map.get("knows"), Some(&2));
2536    }
2537
2538    #[test]
2539    fn test_upsert_entities() {
2540        let kg = new_kg();
2541        kg.create_entities(&[Entity {
2542            name: "A".into(),
2543            entity_type: "OldType".into(),
2544            observations: vec!["old".into()],
2545        }])
2546        .unwrap();
2547        kg.create_relations(&[Relation {
2548            from: "A".into(),
2549            to: "A".into(),
2550            relation_type: "self".into(),
2551        }])
2552        .unwrap();
2553
2554        // Upsert retypes an exact-name entity and only adds novel observations.
2555        kg.upsert_entities(&[Entity {
2556            name: "A".into(),
2557            entity_type: "NewType".into(),
2558            observations: vec!["old".into(), "new".into()],
2559        }])
2560        .unwrap();
2561
2562        assert_eq!(kg.get_entity_count().unwrap(), 1);
2563        let ent = kg.get_entity("A").unwrap().unwrap();
2564        assert_eq!(ent.entity_type, "NewType");
2565        assert_eq!(
2566            ent.observations
2567                .iter()
2568                .map(|o| o.body.as_str())
2569                .collect::<Vec<_>>(),
2570            ["old", "new"]
2571        );
2572
2573        let type_counts: FxHashMap<_, _> = kg.entity_type_counts().into_iter().collect();
2574        assert_eq!(type_counts.get("OldType"), None);
2575        assert_eq!(type_counts.get("NewType"), Some(&1));
2576
2577        assert_eq!(
2578            kg.search_relations(Some("A"), Some("A"), Some("self"), None),
2579            [Relation {
2580                from: "A".into(),
2581                to: "A".into(),
2582                relation_type: "self".into(),
2583            }]
2584        );
2585    }
2586
2587    #[test]
2588    fn test_merge_entities() {
2589        let kg = new_kg();
2590        kg.create_entities(&[
2591            Entity {
2592                name: "source".into(),
2593                entity_type: "t".into(),
2594                observations: vec!["src_obs".into()],
2595            },
2596            Entity {
2597                name: "target".into(),
2598                entity_type: "t".into(),
2599                observations: vec!["tgt_obs".into()],
2600            },
2601        ])
2602        .unwrap();
2603
2604        kg.create_relations(&[Relation {
2605            from: "source".into(),
2606            to: "target".into(),
2607            relation_type: "e".into(),
2608        }])
2609        .unwrap();
2610
2611        let merged = kg.merge_entities("source", "target").unwrap();
2612        assert_eq!(merged.name, "target");
2613        assert!(kg.get_entity("source").unwrap().is_none());
2614    }
2615
2616    #[test]
2617    fn test_find_all_paths() {
2618        let kg = new_kg();
2619        kg.create_entities(&[
2620            Entity {
2621                name: "A".into(),
2622                entity_type: "n".into(),
2623                observations: vec![],
2624            },
2625            Entity {
2626                name: "B".into(),
2627                entity_type: "n".into(),
2628                observations: vec![],
2629            },
2630            Entity {
2631                name: "C".into(),
2632                entity_type: "n".into(),
2633                observations: vec![],
2634            },
2635        ])
2636        .unwrap();
2637
2638        kg.create_relations(&[
2639            Relation {
2640                from: "A".into(),
2641                to: "B".into(),
2642                relation_type: "e".into(),
2643            },
2644            Relation {
2645                from: "B".into(),
2646                to: "C".into(),
2647                relation_type: "e".into(),
2648            },
2649            Relation {
2650                from: "A".into(),
2651                to: "C".into(),
2652                relation_type: "e".into(),
2653            },
2654        ])
2655        .unwrap();
2656
2657        let paths = kg.find_all_paths("A", "C", 5, 10).unwrap();
2658        assert!(paths.len() >= 2);
2659    }
2660
2661    #[test]
2662    fn test_batch_get_entities() {
2663        let kg = new_kg();
2664        kg.create_entities(&[
2665            Entity {
2666                name: "a".into(),
2667                entity_type: "t".into(),
2668                observations: vec![],
2669            },
2670            Entity {
2671                name: "b".into(),
2672                entity_type: "t".into(),
2673                observations: vec![],
2674            },
2675        ])
2676        .unwrap();
2677
2678        let results = kg.batch_get_entities(&["a".into(), "missing".into(), "b".into()]);
2679        assert_eq!(results.len(), 3);
2680        assert!(results[0].is_some());
2681        assert!(results[1].is_none());
2682        assert!(results[2].is_some());
2683    }
2684
2685    #[test]
2686    fn test_export_graph() {
2687        let kg = new_kg();
2688        kg.create_entities(&[Entity {
2689            name: "exp".into(),
2690            entity_type: "t".into(),
2691            observations: vec!["o".into()],
2692        }])
2693        .unwrap();
2694
2695        let exported = kg.export("json", i64::MAX).unwrap();
2696        assert!(exported.contains("exp"));
2697        assert!(exported.contains("o"));
2698    }
2699
2700    #[test]
2701    fn test_graph_stats() {
2702        let kg = new_kg();
2703        assert_eq!(kg.get_entity_count().unwrap(), 0);
2704        assert_eq!(kg.get_relation_count().unwrap(), 0);
2705
2706        kg.create_entities(&[Entity {
2707            name: "s".into(),
2708            entity_type: "t".into(),
2709            observations: vec![],
2710        }])
2711        .unwrap();
2712
2713        assert_eq!(kg.get_entity_count().unwrap(), 1);
2714    }
2715
2716    #[test]
2717    fn test_read_graph_filtered() {
2718        let kg = new_kg();
2719        kg.create_entities(&[
2720            Entity {
2721                name: "p1".into(),
2722                entity_type: "person".into(),
2723                observations: vec![],
2724            },
2725            Entity {
2726                name: "p2".into(),
2727                entity_type: "place".into(),
2728                observations: vec![],
2729            },
2730        ])
2731        .unwrap();
2732
2733        let out = kg.read_graph_filtered(Some("person"), 0, 10).unwrap();
2734        let v: Value = serde_json::from_str(&out).unwrap();
2735        assert_eq!(v["entities"].as_array().unwrap().len(), 1);
2736        assert_eq!(v["entities"][0]["name"], "p1");
2737    }
2738
2739    #[test]
2740    fn test_wipe() {
2741        let kg = new_kg();
2742        kg.create_entities(&[Entity {
2743            name: "w".into(),
2744            entity_type: "t".into(),
2745            observations: vec!["o".into()],
2746        }])
2747        .unwrap();
2748        assert_eq!(kg.get_entity_count().unwrap(), 1);
2749
2750        kg.wipe().unwrap();
2751        assert_eq!(kg.get_entity_count().unwrap(), 0);
2752    }
2753
2754    #[test]
2755    fn test_push_json_str() {
2756        let mut buf = String::new();
2757        push_json_str(&mut buf, "hello");
2758        assert_eq!(buf, "\"hello\"");
2759        let mut buf = String::new();
2760        push_json_str(&mut buf, "he\"llo");
2761        assert_eq!(buf, "\"he\\\"llo\"");
2762    }
2763
2764    // ── create_entities edge cases ────────────────────────────────────
2765
2766    #[test]
2767    fn test_create_entities_empty_input() {
2768        let kg = new_kg();
2769        let created = kg.create_entities(&[]).unwrap();
2770        assert!(created.is_empty());
2771    }
2772
2773    #[test]
2774    fn test_create_entities_skip_empty_name() {
2775        let kg = new_kg();
2776        let created = kg
2777            .create_entities(&[Entity {
2778                name: "".into(),
2779                entity_type: "t".into(),
2780                observations: vec![],
2781            }])
2782            .unwrap();
2783        assert!(created.is_empty());
2784        assert_eq!(kg.get_entity_count().unwrap(), 0);
2785    }
2786
2787    #[test]
2788    fn test_create_entities_duplicate_names() {
2789        let kg = new_kg();
2790        let e = Entity {
2791            name: "dup".into(),
2792            entity_type: "t".into(),
2793            observations: vec!["obs".into()],
2794        };
2795        let first = kg.create_entities(std::slice::from_ref(&e)).unwrap();
2796        assert_eq!(first.len(), 1);
2797        let second = kg.create_entities(&[e]).unwrap();
2798        assert!(second.is_empty());
2799        assert_eq!(kg.get_entity_count().unwrap(), 1);
2800    }
2801
2802    #[test]
2803    fn test_create_entities_partial_duplicates() {
2804        let kg = new_kg();
2805        let created = kg
2806            .create_entities(&[
2807                Entity {
2808                    name: "a".into(),
2809                    entity_type: "t".into(),
2810                    observations: vec![],
2811                },
2812                Entity {
2813                    name: "b".into(),
2814                    entity_type: "t".into(),
2815                    observations: vec![],
2816                },
2817            ])
2818            .unwrap();
2819        assert_eq!(created.len(), 2);
2820
2821        let second = kg
2822            .create_entities(&[
2823                Entity {
2824                    name: "b".into(),
2825                    entity_type: "t".into(),
2826                    observations: vec![],
2827                },
2828                Entity {
2829                    name: "c".into(),
2830                    entity_type: "t".into(),
2831                    observations: vec![],
2832                },
2833            ])
2834            .unwrap();
2835        assert_eq!(second.len(), 1); // only c created
2836        assert_eq!(second[0].name, "c");
2837        assert_eq!(kg.get_entity_count().unwrap(), 3);
2838    }
2839
2840    #[test]
2841    fn test_create_entities_mixed_empty_and_valid() {
2842        let kg = new_kg();
2843        let created = kg
2844            .create_entities(&[
2845                Entity {
2846                    name: "".into(),
2847                    entity_type: "t".into(),
2848                    observations: vec![],
2849                },
2850                Entity {
2851                    name: "valid".into(),
2852                    entity_type: "t".into(),
2853                    observations: vec![],
2854                },
2855                Entity {
2856                    name: "".into(),
2857                    entity_type: "t".into(),
2858                    observations: vec![],
2859                },
2860            ])
2861            .unwrap();
2862        assert_eq!(created.len(), 1);
2863        assert_eq!(created[0].name, "valid");
2864        assert_eq!(kg.get_entity_count().unwrap(), 1);
2865    }
2866
2867    #[test]
2868    fn test_create_entities_same_name_in_batch() {
2869        let kg = new_kg();
2870        let created = kg
2871            .create_entities(&[
2872                Entity {
2873                    name: "dup_in_batch".into(),
2874                    entity_type: "t".into(),
2875                    observations: vec![],
2876                },
2877                Entity {
2878                    name: "dup_in_batch".into(),
2879                    entity_type: "t".into(),
2880                    observations: vec![],
2881                },
2882            ])
2883            .unwrap();
2884        assert_eq!(created.len(), 1);
2885        assert_eq!(kg.get_entity_count().unwrap(), 1);
2886    }
2887
2888    // ── create_relations edge cases ───────────────────────────────────
2889
2890    #[test]
2891    fn test_create_relations_empty_input() {
2892        let kg = new_kg();
2893        let rels = kg.create_relations(&[]).unwrap();
2894        assert!(rels.is_empty());
2895    }
2896
2897    #[test]
2898    fn test_create_relations_nonexistent_from() {
2899        let kg = new_kg();
2900        kg.create_entities(&[Entity {
2901            name: "B".into(),
2902            entity_type: "t".into(),
2903            observations: vec![],
2904        }])
2905        .unwrap();
2906
2907        let rels = kg
2908            .create_relations(&[Relation {
2909                from: "A".into(),
2910                to: "B".into(),
2911                relation_type: "e".into(),
2912            }])
2913            .unwrap();
2914        assert!(rels.is_empty());
2915        assert_eq!(kg.get_relation_count().unwrap(), 0);
2916    }
2917
2918    #[test]
2919    fn test_create_relations_nonexistent_to() {
2920        let kg = new_kg();
2921        kg.create_entities(&[Entity {
2922            name: "A".into(),
2923            entity_type: "t".into(),
2924            observations: vec![],
2925        }])
2926        .unwrap();
2927
2928        let rels = kg
2929            .create_relations(&[Relation {
2930                from: "A".into(),
2931                to: "B".into(),
2932                relation_type: "e".into(),
2933            }])
2934            .unwrap();
2935        assert!(rels.is_empty());
2936        assert_eq!(kg.get_relation_count().unwrap(), 0);
2937    }
2938
2939    #[test]
2940    fn test_create_relations_both_nonexistent() {
2941        let kg = new_kg();
2942        let rels = kg
2943            .create_relations(&[Relation {
2944                from: "A".into(),
2945                to: "B".into(),
2946                relation_type: "e".into(),
2947            }])
2948            .unwrap();
2949        assert!(rels.is_empty());
2950    }
2951
2952    #[test]
2953    fn test_create_relations_self_loop() {
2954        let kg = new_kg();
2955        kg.create_entities(&[Entity {
2956            name: "self".into(),
2957            entity_type: "t".into(),
2958            observations: vec![],
2959        }])
2960        .unwrap();
2961
2962        let rels = kg
2963            .create_relations(&[Relation {
2964                from: "self".into(),
2965                to: "self".into(),
2966                relation_type: "loop".into(),
2967            }])
2968            .unwrap();
2969        assert_eq!(rels.len(), 1);
2970        assert_eq!(kg.get_relation_count().unwrap(), 1);
2971        assert_eq!(kg.degree("self", Direction::Outgoing).unwrap(), 1);
2972        assert_eq!(kg.degree("self", Direction::Incoming).unwrap(), 1);
2973    }
2974
2975    #[test]
2976    fn test_create_relations_duplicate() {
2977        let kg = new_kg();
2978        kg.create_entities(&[
2979            Entity {
2980                name: "A".into(),
2981                entity_type: "t".into(),
2982                observations: vec![],
2983            },
2984            Entity {
2985                name: "B".into(),
2986                entity_type: "t".into(),
2987                observations: vec![],
2988            },
2989        ])
2990        .unwrap();
2991
2992        let r = Relation {
2993            from: "A".into(),
2994            to: "B".into(),
2995            relation_type: "e".into(),
2996        };
2997        let first = kg.create_relations(std::slice::from_ref(&r)).unwrap();
2998        assert_eq!(first.len(), 1);
2999
3000        let second = kg.create_relations(&[r]).unwrap();
3001        assert!(second.is_empty());
3002        assert_eq!(kg.get_relation_count().unwrap(), 1);
3003    }
3004
3005    #[test]
3006    fn test_create_relations_new_type_auto_created() {
3007        let kg = new_kg();
3008        kg.create_entities(&[
3009            Entity {
3010                name: "A".into(),
3011                entity_type: "t".into(),
3012                observations: vec![],
3013            },
3014            Entity {
3015                name: "B".into(),
3016                entity_type: "t".into(),
3017                observations: vec![],
3018            },
3019        ])
3020        .unwrap();
3021
3022        let rels = kg
3023            .create_relations(&[Relation {
3024                from: "A".into(),
3025                to: "B".into(),
3026                relation_type: "brand_new_type".into(),
3027            }])
3028            .unwrap();
3029        assert_eq!(rels.len(), 1);
3030
3031        let counts = kg.relation_type_counts();
3032        let map: FxHashMap<_, _> = counts.into_iter().collect();
3033        assert_eq!(map.get("brand_new_type"), Some(&1));
3034    }
3035
3036    #[test]
3037    fn test_create_relations_degree_updates() {
3038        let kg = new_kg();
3039        kg.create_entities(&[
3040            Entity {
3041                name: "A".into(),
3042                entity_type: "t".into(),
3043                observations: vec![],
3044            },
3045            Entity {
3046                name: "B".into(),
3047                entity_type: "t".into(),
3048                observations: vec![],
3049            },
3050            Entity {
3051                name: "C".into(),
3052                entity_type: "t".into(),
3053                observations: vec![],
3054            },
3055        ])
3056        .unwrap();
3057
3058        kg.create_relations(&[
3059            Relation {
3060                from: "A".into(),
3061                to: "B".into(),
3062                relation_type: "e".into(),
3063            },
3064            Relation {
3065                from: "A".into(),
3066                to: "C".into(),
3067                relation_type: "e".into(),
3068            },
3069        ])
3070        .unwrap();
3071
3072        assert_eq!(kg.degree("A", Direction::Outgoing).unwrap(), 2);
3073        assert_eq!(kg.degree("A", Direction::Incoming).unwrap(), 0);
3074        assert_eq!(kg.degree("B", Direction::Incoming).unwrap(), 1);
3075        assert_eq!(kg.degree("C", Direction::Incoming).unwrap(), 1);
3076        assert_eq!(kg.degree("A", Direction::Both).unwrap(), 2);
3077    }
3078
3079    #[test]
3080    fn test_create_relations_delete_and_recreate() {
3081        let kg = new_kg();
3082        kg.create_entities(&[
3083            Entity {
3084                name: "A".into(),
3085                entity_type: "t".into(),
3086                observations: vec![],
3087            },
3088            Entity {
3089                name: "B".into(),
3090                entity_type: "t".into(),
3091                observations: vec![],
3092            },
3093        ])
3094        .unwrap();
3095
3096        let r = Relation {
3097            from: "A".into(),
3098            to: "B".into(),
3099            relation_type: "e".into(),
3100        };
3101        kg.create_relations(std::slice::from_ref(&r)).unwrap();
3102        assert_eq!(kg.get_relation_count().unwrap(), 1);
3103
3104        kg.delete_relations(std::slice::from_ref(&r)).unwrap();
3105        assert_eq!(kg.get_relation_count().unwrap(), 0);
3106
3107        // Recreate after delete
3108        let re = kg.create_relations(&[r]).unwrap();
3109        assert_eq!(re.len(), 1);
3110        assert_eq!(kg.get_relation_count().unwrap(), 1);
3111    }
3112
3113    // ── Integration edge cases ────────────────────────────────────────
3114
3115    #[test]
3116    fn test_create_entities_then_relations_then_delete_entity_with_relations() {
3117        let kg = new_kg();
3118        kg.create_entities(&[
3119            Entity {
3120                name: "A".into(),
3121                entity_type: "t".into(),
3122                observations: vec![],
3123            },
3124            Entity {
3125                name: "B".into(),
3126                entity_type: "t".into(),
3127                observations: vec![],
3128            },
3129        ])
3130        .unwrap();
3131        kg.create_relations(&[Relation {
3132            from: "A".into(),
3133            to: "B".into(),
3134            relation_type: "e".into(),
3135        }])
3136        .unwrap();
3137
3138        assert_eq!(kg.get_relation_count().unwrap(), 1);
3139
3140        // Deleting entity A should also delete the relation
3141        kg.delete_entities(&["A".into()]).unwrap();
3142        assert!(kg.get_entity("A").unwrap().is_none());
3143        assert_eq!(kg.get_relation_count().unwrap(), 0);
3144    }
3145
3146    #[test]
3147    fn test_graph_stats_after_entity_with_observations() {
3148        let kg = new_kg();
3149        kg.create_entities(&[Entity {
3150            name: "stat".into(),
3151            entity_type: "t".into(),
3152            observations: vec!["o1".into(), "o2".into(), "o3".into()],
3153        }])
3154        .unwrap();
3155
3156        let ecount = kg.get_entity_count().unwrap();
3157        // graph_stat for observations is tracked but there's no public getter for it
3158        assert_eq!(ecount, 1);
3159
3160        // delete reverts stats
3161        kg.delete_entities(&["stat".into()]).unwrap();
3162        assert_eq!(kg.get_entity_count().unwrap(), 0);
3163    }
3164
3165    // ── Helpers for the fix-specific suites ────────────────────────────────
3166
3167    fn new_kg_with_pool(read_pool_size: usize) -> TestKg {
3168        use std::sync::atomic::AtomicU64;
3169        static COUNTER: AtomicU64 = AtomicU64::new(1_000_000);
3170        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
3171        let path = std::env::temp_dir().join(format!("kg_pool_{}_{}.db", std::process::id(), n));
3172        cleanup_db(&path);
3173        let kg = GraphHandle::new(
3174            &path,
3175            Durability::Async,
3176            SqliteTuning::default(),
3177            NonZeroUsize::new(10_000).unwrap(),
3178            read_pool_size,
3179        )
3180        .expect("create KG");
3181        TestKg(kg, path)
3182    }
3183
3184    fn seed_line(kg: &GraphHandle, n: usize) {
3185        let entities: Vec<Entity> = (0..n)
3186            .map(|i| Entity {
3187                name: format!("n{i}"),
3188                entity_type: "node".into(),
3189                observations: vec![format!("obs of n{i}").into()],
3190            })
3191            .collect();
3192        kg.create_entities(&entities).unwrap();
3193        let rels: Vec<Relation> = (0..n.saturating_sub(1))
3194            .map(|i| Relation {
3195                from: format!("n{i}"),
3196                to: format!("n{}", i + 1),
3197                relation_type: "edge".into(),
3198            })
3199            .collect();
3200        if !rels.is_empty() {
3201            kg.create_relations(&rels).unwrap();
3202        }
3203    }
3204
3205    fn count_relations(graph_json: &str) -> usize {
3206        let v: Value = serde_json::from_str(graph_json).unwrap();
3207        v["relations"].as_array().unwrap().len()
3208    }
3209
3210    fn count_entities(graph_json: &str) -> usize {
3211        let v: Value = serde_json::from_str(graph_json).unwrap();
3212        v["entities"].as_array().unwrap().len()
3213    }
3214
3215    // ── Fix #1: reader pool / concurrency ──────────────────────────────────
3216
3217    #[test]
3218    fn test_pool_size_one_still_works() {
3219        let kg = new_kg_with_pool(1);
3220        seed_line(&kg, 5);
3221        assert_eq!(kg.get_entity_count().unwrap(), 5);
3222        assert!(kg.get_entity("n2").unwrap().is_some());
3223        let g = kg.read_graph_filtered(None, 0, usize::MAX).unwrap();
3224        assert_eq!(count_entities(&g), 5);
3225    }
3226
3227    #[test]
3228    fn test_reads_see_committed_writes() {
3229        // A read on a pool connection must observe a just-committed write made on
3230        // the writer connection (WAL visibility).
3231        let kg = new_kg_with_pool(4);
3232        kg.create_entities(&[Entity {
3233            name: "fresh".into(),
3234            entity_type: "t".into(),
3235            observations: vec!["v".into()],
3236        }])
3237        .unwrap();
3238        // get_entity goes through the reader pool.
3239        let got = kg.get_entity("fresh").unwrap().unwrap();
3240        assert_eq!(
3241            got.observations
3242                .iter()
3243                .map(|o| o.body.as_str())
3244                .collect::<Vec<_>>(),
3245            vec!["v"]
3246        );
3247    }
3248
3249    #[test]
3250    fn test_concurrent_readers_consistent() {
3251        // Many readers hammering the pool while the writer mutates must never
3252        // panic, deadlock, or observe a torn graph. The final counts must match.
3253        let kg = new_kg_with_pool(4);
3254        seed_line(&kg, 50);
3255
3256        std::thread::scope(|s| {
3257            // 8 reader threads.
3258            for _ in 0..8 {
3259                s.spawn(|| {
3260                    for _ in 0..200 {
3261                        let _ = kg.get_entity("n10");
3262                        let _ = kg.search_nodes_filtered("obs", None, 0, 10);
3263                        let _ = kg.read_graph_filtered(None, 0, 100);
3264                        let _ = kg.get_entity_count();
3265                        let _ = kg.neighbors("n10", Direction::Both, None, 2);
3266                    }
3267                });
3268            }
3269            // 1 writer thread adding more entities concurrently.
3270            s.spawn(|| {
3271                for i in 100..160 {
3272                    kg.create_entities(&[Entity {
3273                        name: format!("w{i}"),
3274                        entity_type: "node".into(),
3275                        observations: vec![format!("w obs {i}").into()],
3276                    }])
3277                    .unwrap();
3278                }
3279            });
3280        });
3281
3282        // 50 seeded + 60 written.
3283        assert_eq!(kg.get_entity_count().unwrap(), 110);
3284        assert!(kg.get_entity("w159").unwrap().is_some());
3285    }
3286
3287    #[test]
3288    fn test_reader_pool_rejects_writes_internally() {
3289        // Sanity: query_only readers cannot mutate. We can't call a write through
3290        // the pool directly, but we can confirm a read method that *would* have
3291        // inserted (search_relations resolving a missing type) does not create a
3292        // phantom type — see the dedicated test below — and that reads under a
3293        // size-1 pool serialize correctly without deadlock.
3294        let kg = new_kg_with_pool(1);
3295        seed_line(&kg, 3);
3296        std::thread::scope(|s| {
3297            for _ in 0..4 {
3298                s.spawn(|| {
3299                    for _ in 0..100 {
3300                        let _ = kg.read_graph_filtered(None, 0, 10);
3301                    }
3302                });
3303            }
3304        });
3305        assert_eq!(kg.get_entity_count().unwrap(), 3);
3306    }
3307
3308    // ── Fix #6: read_graph relation scoping + export bound ─────────────────
3309
3310    #[test]
3311    fn test_read_graph_relations_scoped_to_page() {
3312        let kg = new_kg_with_pool(2);
3313        // n0 -> n1 -> n2 -> n3 (4 entities, 3 edges).
3314        seed_line(&kg, 4);
3315
3316        // Full page: all 3 edges present.
3317        let full = kg.read_graph_filtered(None, 0, usize::MAX).unwrap();
3318        assert_eq!(count_entities(&full), 4);
3319        assert_eq!(count_relations(&full), 3);
3320
3321        // Page of only the first entity (n0): its only edge n0->n1 has an
3322        // endpoint (n1) outside the page, so no relations are returned.
3323        let page1 = kg.read_graph_filtered(None, 0, 1).unwrap();
3324        assert_eq!(count_entities(&page1), 1);
3325        assert_eq!(count_relations(&page1), 0);
3326
3327        // Page of first two entities (n0, n1): edge n0->n1 fully inside, n1->n2
3328        // straddles the boundary and is excluded.
3329        let page2 = kg.read_graph_filtered(None, 0, 2).unwrap();
3330        assert_eq!(count_entities(&page2), 2);
3331        assert_eq!(count_relations(&page2), 1);
3332    }
3333
3334    #[test]
3335    fn test_read_graph_pagination_offset() {
3336        let kg = new_kg_with_pool(2);
3337        seed_line(&kg, 5);
3338        let g = kg.read_graph_filtered(None, 2, 2).unwrap();
3339        assert_eq!(count_entities(&g), 2);
3340        // Entities are ordered by id; offset 2 skips n0, n1.
3341        assert!(!g.contains("\"n0\""));
3342        assert!(!g.contains("\"n1\""));
3343        assert!(g.contains("\"n2\""));
3344        assert!(g.contains("\"n3\""));
3345    }
3346
3347    #[test]
3348    fn test_read_graph_empty() {
3349        let kg = new_kg_with_pool(2);
3350        let g = kg.read_graph_filtered(None, 0, usize::MAX).unwrap();
3351        assert_eq!(g, r#"{"entities":[],"relations":[]}"#);
3352    }
3353
3354    #[test]
3355    fn test_read_graph_filtered_by_type() {
3356        let kg = new_kg_with_pool(2);
3357        kg.create_entities(&[
3358            Entity {
3359                name: "p1".into(),
3360                entity_type: "person".into(),
3361                observations: vec![],
3362            },
3363            Entity {
3364                name: "q1".into(),
3365                entity_type: "place".into(),
3366                observations: vec![],
3367            },
3368            Entity {
3369                name: "p2".into(),
3370                entity_type: "person".into(),
3371                observations: vec![],
3372            },
3373        ])
3374        .unwrap();
3375        let g = kg
3376            .read_graph_filtered(Some("person"), 0, usize::MAX)
3377            .unwrap();
3378        assert_eq!(count_entities(&g), 2);
3379        assert!(g.contains("\"p1\""));
3380        assert!(g.contains("\"p2\""));
3381        assert!(!g.contains("\"q1\""));
3382    }
3383
3384    #[test]
3385    fn test_export_respects_max_rows() {
3386        let kg = new_kg_with_pool(2);
3387        seed_line(&kg, 5);
3388
3389        // Unbounded export returns everything.
3390        let full = kg.export("json", i64::MAX).unwrap();
3391        assert_eq!(count_entities(&full), 5);
3392        assert_eq!(count_relations(&full), 4);
3393
3394        // Capped export truncates both arrays to the cap.
3395        let capped = kg.export("json", 2).unwrap();
3396        assert_eq!(count_entities(&capped), 2);
3397        assert_eq!(count_relations(&capped), 2);
3398    }
3399
3400    #[test]
3401    fn test_export_negative_max_rows_is_unbounded() {
3402        let kg = new_kg_with_pool(2);
3403        seed_line(&kg, 3);
3404        // SQLite treats a negative LIMIT as "no limit".
3405        let out = kg.export("json", -1).unwrap();
3406        assert_eq!(count_entities(&out), 3);
3407    }
3408
3409    // ── Fix #8: writes remain correct without the per-write PRAGMA optimize ─
3410
3411    #[test]
3412    fn test_many_small_write_batches_stay_consistent() {
3413        let kg = new_kg_with_pool(2);
3414        for i in 0..100 {
3415            kg.create_entities(&[Entity {
3416                name: format!("e{i}"),
3417                entity_type: "t".into(),
3418                observations: vec![format!("o{i}").into()],
3419            }])
3420            .unwrap();
3421        }
3422        assert_eq!(kg.get_entity_count().unwrap(), 100);
3423        // Search must still find a needle inserted across many tiny batches,
3424        // proving FTS stayed consistent without per-write optimization.
3425        let hits = kg.search_nodes_filtered("e57", None, 0, 10);
3426        assert!(hits.iter().any(|e| e.name == "e57"));
3427    }
3428
3429    // ── Fix #9: wipe fully resets the FTS indexes ──────────────────────────
3430
3431    #[test]
3432    fn test_wipe_clears_name_and_obs_fts() {
3433        let kg = new_kg_with_pool(2);
3434        kg.create_entities(&[Entity {
3435            name: "Einstein".into(),
3436            entity_type: "scientist".into(),
3437            observations: vec!["physics".into()],
3438        }])
3439        .unwrap();
3440
3441        // Both FTS indexes resolve before the wipe.
3442        assert_eq!(kg.search_nodes_filtered("Einstein", None, 0, 10).len(), 1);
3443        assert_eq!(kg.search_nodes_filtered("physics", None, 0, 10).len(), 1);
3444
3445        kg.wipe().unwrap();
3446
3447        // After wipe both indexes must be empty (a bare DELETE on an
3448        // external-content FTS5 table would have left stale rowids behind).
3449        assert_eq!(kg.get_entity_count().unwrap(), 0);
3450        assert!(kg.search_nodes_filtered("Einstein", None, 0, 10).is_empty());
3451        assert!(kg.search_nodes_filtered("physics", None, 0, 10).is_empty());
3452    }
3453
3454    #[test]
3455    fn test_wipe_then_recreate_search_works() {
3456        // Recreating the same names after a wipe must produce a clean, searchable
3457        // index — not a corrupted one or duplicate FTS rows.
3458        let kg = new_kg_with_pool(2);
3459        kg.create_entities(&[Entity {
3460            name: "Einstein".into(),
3461            entity_type: "scientist".into(),
3462            observations: vec!["physics".into()],
3463        }])
3464        .unwrap();
3465        kg.wipe().unwrap();
3466
3467        kg.create_entities(&[Entity {
3468            name: "Einstein".into(),
3469            entity_type: "scientist".into(),
3470            observations: vec!["physics".into(), "relativity".into()],
3471        }])
3472        .unwrap();
3473
3474        let by_name = kg.search_nodes_filtered("Einstein", None, 0, 10);
3475        assert_eq!(by_name.len(), 1, "exactly one Einstein after recreate");
3476        let by_obs = kg.search_nodes_filtered("relativity", None, 0, 10);
3477        assert_eq!(by_obs.len(), 1);
3478        assert_eq!(kg.get_entity_count().unwrap(), 1);
3479    }
3480
3481    // ── Read-only type/entity resolution (introduced by the reader pool) ───
3482
3483    #[test]
3484    fn test_search_relations_missing_type_returns_empty() {
3485        let kg = new_kg_with_pool(2);
3486        seed_line(&kg, 3); // edges of type "edge"
3487        // A filter for a relation type that does not exist must return nothing,
3488        // not every relation — and must not create a phantom type row.
3489        let r = kg.search_relations(None, None, Some("does_not_exist"), None);
3490        assert!(r.is_empty());
3491        // The phantom type must not have been inserted by the read.
3492        let types = kg.relation_type_counts();
3493        assert!(types.iter().all(|(t, _)| t != "does_not_exist"));
3494    }
3495
3496    #[test]
3497    fn test_entity_type_exists() {
3498        let kg = new_kg_with_pool(2);
3499        kg.create_entities(&[Entity {
3500            name: "a".into(),
3501            entity_type: "person".into(),
3502            observations: vec![],
3503        }])
3504        .unwrap();
3505        assert!(kg.entity_type_exists("person"));
3506        assert!(!kg.entity_type_exists("persn"));
3507        // The negative read must not have inserted a phantom type row.
3508        let types = kg.entity_type_counts();
3509        assert!(types.iter().all(|(t, _)| t != "persn"));
3510    }
3511
3512    #[test]
3513    fn test_type_descriptions_registry_and_catalog() {
3514        let kg = new_kg_with_pool(2);
3515        // A description registers a type before any member exists.
3516        kg.set_type_description(0, "person", Some("A human being or persona"))
3517            .unwrap();
3518        let catalog = kg.entity_type_catalog();
3519        assert_eq!(
3520            catalog,
3521            vec![(
3522                "person".into(),
3523                0usize,
3524                Some("A human being or persona".into())
3525            )]
3526        );
3527        // The count-only view and existence follow the same row.
3528        assert!(kg.entity_type_exists("person"));
3529        assert!(
3530            kg.entity_type_counts().is_empty(),
3531            "count 0 types stay out of counts"
3532        );
3533
3534        // Setting again updates in place, keeps count 0 while unused.
3535        kg.set_type_description(0, "person", Some("A living human"))
3536            .unwrap();
3537        let catalog = kg.entity_type_catalog();
3538        assert_eq!(
3539            catalog,
3540            vec![("person".into(), 0usize, Some("A living human".into()))]
3541        );
3542
3543        // Clearing removes the description. The described row loses its desc,
3544        // and with count still 0 it leaves the catalog (no member, no desc).
3545        kg.set_type_description(0, "person", None).unwrap();
3546        assert!(kg.entity_type_catalog().is_empty());
3547        assert!(
3548            kg.entity_type_exists("person"),
3549            "the row stays; only the desc clears"
3550        );
3551
3552        // A member makes the type count 1; the desc rides along and the type
3553        // stays in the catalog even with the desc cleared.
3554        kg.create_entities(&[Entity {
3555            name: "alice".into(),
3556            entity_type: "person".into(),
3557            observations: vec![],
3558        }])
3559        .unwrap();
3560        assert_eq!(
3561            kg.entity_type_catalog(),
3562            vec![("person".into(), 1usize, None)]
3563        );
3564        assert_eq!(kg.entity_type_counts(), vec![("person".into(), 1usize)]);
3565
3566        // Relation kinds take the same path.
3567        kg.set_type_description(1, "works_at", Some("Employment link"))
3568            .unwrap();
3569        assert_eq!(
3570            kg.relation_type_catalog(),
3571            vec![("works_at".into(), 0usize, Some("Employment link".into()))]
3572        );
3573        assert!(kg.relation_type_exists("works_at"));
3574    }
3575
3576    #[test]
3577    fn test_type_description_length_not_capped_at_core() {
3578        // The server layer enforces the byte cap; the core accepts any text.
3579        let kg = new_kg_with_pool(2);
3580        let long = "x".repeat(20_000);
3581        kg.set_type_description(0, "person", Some(long.as_str()))
3582            .unwrap();
3583        assert_eq!(kg.entity_type_catalog().len(), 1);
3584    }
3585
3586    #[test]
3587    fn test_relation_type_exists() {
3588        let kg = new_kg_with_pool(2);
3589        kg.create_entities(&[
3590            Entity {
3591                name: "a".into(),
3592                entity_type: "person".into(),
3593                observations: vec![],
3594            },
3595            Entity {
3596                name: "b".into(),
3597                entity_type: "person".into(),
3598                observations: vec![],
3599            },
3600        ])
3601        .unwrap();
3602        kg.create_relations(&[Relation {
3603            from: "a".into(),
3604            to: "b".into(),
3605            relation_type: "knows".into(),
3606        }])
3607        .unwrap();
3608        assert!(kg.relation_type_exists("knows"));
3609        assert!(!kg.relation_type_exists("unknown_kind"));
3610        // The negative read must not have inserted a phantom type row.
3611        let types = kg.relation_type_counts();
3612        assert!(types.iter().all(|(t, _)| t != "unknown_kind"));
3613    }
3614
3615    #[test]
3616    fn test_search_relations_missing_from_returns_empty() {
3617        let kg = new_kg_with_pool(2);
3618        seed_line(&kg, 3);
3619        let r = kg.search_relations(Some("ghost"), None, None, None);
3620        assert!(r.is_empty(), "missing 'from' must not match every relation");
3621    }
3622
3623    #[test]
3624    fn test_search_relations_existing_filters_still_work() {
3625        let kg = new_kg_with_pool(2);
3626        seed_line(&kg, 3);
3627        let r = kg.search_relations(Some("n0"), None, Some("edge"), None);
3628        assert_eq!(r.len(), 1);
3629        assert_eq!(r[0].from, "n0");
3630        assert_eq!(r[0].to, "n1");
3631    }
3632
3633    #[test]
3634    fn test_neighbors_missing_type_returns_only_start() {
3635        let kg = new_kg_with_pool(2);
3636        seed_line(&kg, 3);
3637        let json = kg
3638            .neighbors("n0", Direction::Both, Some("nonexistent"), 2)
3639            .unwrap();
3640        // No edge matches the bogus type, so only the start node comes back.
3641        assert_eq!(count_entities(&json), 1);
3642        assert_eq!(count_relations(&json), 0);
3643    }
3644
3645    #[test]
3646    fn test_neighbors_existing_type_filters() {
3647        let kg = new_kg_with_pool(2);
3648        kg.create_entities(&[
3649            Entity {
3650                name: "a".into(),
3651                entity_type: "n".into(),
3652                observations: vec![],
3653            },
3654            Entity {
3655                name: "b".into(),
3656                entity_type: "n".into(),
3657                observations: vec![],
3658            },
3659            Entity {
3660                name: "c".into(),
3661                entity_type: "n".into(),
3662                observations: vec![],
3663            },
3664        ])
3665        .unwrap();
3666        kg.create_relations(&[
3667            Relation {
3668                from: "a".into(),
3669                to: "b".into(),
3670                relation_type: "knows".into(),
3671            },
3672            Relation {
3673                from: "a".into(),
3674                to: "c".into(),
3675                relation_type: "likes".into(),
3676            },
3677        ])
3678        .unwrap();
3679        let json = kg
3680            .neighbors("a", Direction::Outgoing, Some("knows"), 1)
3681            .unwrap();
3682        assert!(json.contains("\"b\""));
3683        assert!(!json.contains("\"c\""));
3684        assert_eq!(count_relations(&json), 1);
3685    }
3686
3687    #[test]
3688    fn test_sqlite_tuning_applied_to_fresh_db() {
3689        use std::sync::atomic::AtomicU64;
3690        static COUNTER: AtomicU64 = AtomicU64::new(2_000_000);
3691        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
3692        let path = std::env::temp_dir().join(format!("kg_tuning_{}_{}.db", std::process::id(), n));
3693        cleanup_db(&path);
3694
3695        let tuning = SqliteTuning {
3696            page_size: 8192,
3697            ..SqliteTuning::default()
3698        };
3699        let kg = TestKg(
3700            GraphHandle::new(
3701                &path,
3702                Durability::Async,
3703                tuning,
3704                NonZeroUsize::new(64).unwrap(),
3705                2,
3706            )
3707            .expect("create KG"),
3708            path.clone(),
3709        );
3710        kg.create_entities(&[Entity {
3711            name: "a".into(),
3712            entity_type: "n".into(),
3713            observations: vec!["o".into()],
3714        }])
3715        .unwrap();
3716
3717        // page_size (fresh-DB only) and auto_vacuum=INCREMENTAL must have taken
3718        // effect, and journal_mode must be WAL.
3719        let probe = Connection::open(&path).unwrap();
3720        let page_size: i64 = probe
3721            .query_row("PRAGMA page_size", [], |r| r.get(0))
3722            .unwrap();
3723        assert_eq!(page_size, 8192);
3724        let auto_vacuum: i64 = probe
3725            .query_row("PRAGMA auto_vacuum", [], |r| r.get(0))
3726            .unwrap();
3727        assert_eq!(auto_vacuum, 2, "expected INCREMENTAL auto_vacuum");
3728        let journal: String = probe
3729            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
3730            .unwrap();
3731        assert_eq!(journal.to_lowercase(), "wal");
3732    }
3733
3734    #[test]
3735    fn test_checkpoint_passive_is_noop_safe() {
3736        let kg = new_kg();
3737        // On an empty / freshly-written DB a passive checkpoint must succeed.
3738        kg.checkpoint_passive().unwrap();
3739        kg.create_entities(&[Entity {
3740            name: "a".into(),
3741            entity_type: "n".into(),
3742            observations: vec!["o".into()],
3743        }])
3744        .unwrap();
3745        // And after a write, repeatedly, without error or deadlock.
3746        kg.checkpoint_passive().unwrap();
3747        kg.checkpoint_passive().unwrap();
3748        // Data is still readable afterwards.
3749        assert!(kg.get_entity("a").unwrap().is_some());
3750    }
3751}