1use anyhow::{Context, Result};
2use std::collections::{BTreeMap, BTreeSet};
3use std::path::Path;
4use tsift_core::{GraphEdge, GraphNode, GraphPath, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION};
5
6fn block_on<F: std::future::Future>(rt: &tokio::runtime::Runtime, f: F) -> F::Output {
7 rt.block_on(f)
8}
9
10pub struct LibsqlGraphStore {
11 conn: libsql::Connection,
12 rt: tokio::runtime::Runtime,
13}
14
15impl LibsqlGraphStore {
16 pub fn open(db_path: &Path) -> Result<Self> {
17 if let Some(parent) = db_path.parent() {
18 std::fs::create_dir_all(parent).with_context(|| {
19 format!("creating libsql graph substrate dir: {}", parent.display())
20 })?;
21 }
22 let rt = tokio::runtime::Runtime::new().context("creating tokio runtime for libsql")?;
23 let db = block_on(&rt, libsql::Builder::new_local(db_path).build())
24 .with_context(|| format!("opening libsql graph substrate db: {}", db_path.display()))?;
25 let conn = db.connect().with_context(|| {
26 format!(
27 "connecting to libsql graph substrate db: {}",
28 db_path.display()
29 )
30 })?;
31 let store = Self { conn, rt };
32 store.init_schema()?;
33 Ok(store)
34 }
35
36 pub fn open_remote(url: &str, auth_token: &str) -> Result<Self> {
37 let rt =
38 tokio::runtime::Runtime::new().context("creating tokio runtime for libsql remote")?;
39 let db = block_on(
40 &rt,
41 libsql::Builder::new_remote(url.to_string(), auth_token.to_string()).build(),
42 )
43 .context("building libsql remote database")?;
44 let conn = db
45 .connect()
46 .context("connecting to libsql remote database")?;
47 let store = Self { conn, rt };
48 store.init_schema()?;
49 Ok(store)
50 }
51
52 pub fn in_memory() -> Result<Self> {
53 let rt = tokio::runtime::Runtime::new().context("creating tokio runtime for libsql")?;
54 let db = block_on(&rt, libsql::Builder::new_local(":memory:").build())
55 .context("opening in-memory libsql database")?;
56 let conn = db
57 .connect()
58 .context("connecting to in-memory libsql database")?;
59 let store = Self { conn, rt };
60 store.init_schema()?;
61 Ok(store)
62 }
63
64 fn init_schema(&self) -> Result<()> {
65 block_on(&self.rt, async {
66 self.conn
67 .execute_batch(&format!(
68 r#"
69 PRAGMA foreign_keys = ON;
70 PRAGMA busy_timeout = 5000;
71
72 CREATE TABLE IF NOT EXISTS graph_nodes (
73 id TEXT PRIMARY KEY,
74 kind TEXT NOT NULL,
75 label TEXT NOT NULL,
76 properties_json TEXT NOT NULL DEFAULT '{{}}',
77 provenance_json TEXT NOT NULL DEFAULT '[]',
78 freshness_json TEXT,
79 row_hash TEXT,
80 source_watermark TEXT
81 );
82 CREATE INDEX IF NOT EXISTS idx_graph_nodes_kind
83 ON graph_nodes(kind);
84 CREATE INDEX IF NOT EXISTS idx_graph_nodes_kind_label
85 ON graph_nodes(kind, label, id);
86
87 CREATE TABLE IF NOT EXISTS graph_edges (
88 edge_key TEXT NOT NULL UNIQUE,
89 from_id TEXT NOT NULL,
90 to_id TEXT NOT NULL,
91 kind TEXT NOT NULL,
92 properties_json TEXT NOT NULL DEFAULT '{{}}',
93 provenance_json TEXT NOT NULL DEFAULT '[]',
94 freshness_json TEXT,
95 row_hash TEXT,
96 source_watermark TEXT,
97 PRIMARY KEY (from_id, to_id, kind),
98 FOREIGN KEY (from_id) REFERENCES graph_nodes(id) ON DELETE CASCADE,
99 FOREIGN KEY (to_id) REFERENCES graph_nodes(id) ON DELETE CASCADE
100 );
101 CREATE INDEX IF NOT EXISTS idx_graph_edges_from_kind
102 ON graph_edges(from_id, kind);
103 CREATE INDEX IF NOT EXISTS idx_graph_edges_to_kind
104 ON graph_edges(to_id, kind);
105
106 CREATE TABLE IF NOT EXISTS graph_node_properties (
107 node_id TEXT NOT NULL,
108 key TEXT NOT NULL,
109 value TEXT NOT NULL,
110 PRIMARY KEY (node_id, key),
111 FOREIGN KEY (node_id) REFERENCES graph_nodes(id) ON DELETE CASCADE
112 );
113 CREATE INDEX IF NOT EXISTS idx_graph_node_properties_key_value_node
114 ON graph_node_properties(key, value, node_id);
115
116 CREATE TABLE IF NOT EXISTS graph_edge_properties (
117 edge_key TEXT NOT NULL,
118 key TEXT NOT NULL,
119 value TEXT NOT NULL,
120 PRIMARY KEY (edge_key, key),
121 FOREIGN KEY (edge_key) REFERENCES graph_edges(edge_key) ON DELETE CASCADE
122 );
123 CREATE INDEX IF NOT EXISTS idx_graph_edge_properties_key_value_edge
124 ON graph_edge_properties(key, value, edge_key);
125
126 CREATE TABLE IF NOT EXISTS graph_projection_versions (
127 scope TEXT PRIMARY KEY,
128 projection_version TEXT NOT NULL,
129 content_hash TEXT,
130 source_watermark TEXT,
131 observed_at_unix INTEGER NOT NULL
132 );
133
134 CREATE TABLE IF NOT EXISTS graph_tombstones (
135 row_key TEXT PRIMARY KEY,
136 row_kind TEXT NOT NULL,
137 deleted_at_unix INTEGER NOT NULL
138 );
139
140 PRAGMA user_version = {SQLITE_GRAPH_SCHEMA_VERSION};
141 "#,
142 ))
143 .await
144 .context("initializing libsql graph schema")?;
145 Ok::<(), anyhow::Error>(())
146 })?;
147 Ok(())
148 }
149}
150
151fn to_json<T: serde::Serialize>(value: &T) -> Result<String> {
152 serde_json::to_string(value).map_err(Into::into)
153}
154
155fn optional_to_json<T: serde::Serialize>(value: &Option<T>) -> Result<Option<String>> {
156 value.as_ref().map(to_json).transpose()
157}
158
159fn node_from_row(row: &libsql::Row) -> Result<GraphNode> {
160 let id: String = row.get(0)?;
161 let kind: String = row.get(1)?;
162 let label: String = row.get(2)?;
163 let properties_json: String = row.get(3)?;
164 let provenance_json: String = row.get(4)?;
165 let freshness_json: Option<String> = row.get(5)?;
166 Ok(GraphNode {
167 id,
168 kind,
169 label,
170 properties: serde_json::from_str(&properties_json)?,
171 provenance: serde_json::from_str(&provenance_json)?,
172 freshness: freshness_json
173 .map(|v| serde_json::from_str(&v))
174 .transpose()?,
175 })
176}
177
178fn edge_from_row(row: &libsql::Row) -> Result<GraphEdge> {
179 let edge_key: String = row.get(0)?;
180 let from_id: String = row.get(1)?;
181 let to_id: String = row.get(2)?;
182 let kind: String = row.get(3)?;
183 let properties_json: String = row.get(4)?;
184 let provenance_json: String = row.get(5)?;
185 let freshness_json: Option<String> = row.get(6)?;
186 Ok(GraphEdge {
187 id: edge_key,
188 from_id,
189 to_id,
190 kind,
191 properties: serde_json::from_str(&properties_json)?,
192 provenance: serde_json::from_str(&provenance_json)?,
193 freshness: freshness_json
194 .map(|v| serde_json::from_str(&v))
195 .transpose()?,
196 })
197}
198
199fn stable_graph_edge_id(from_id: &str, to_id: &str, kind: &str) -> String {
200 let raw = serde_json::json!([from_id, kind, to_id]).to_string();
201 format!("edge:{}", blake3::hash(raw.as_bytes()).to_hex())
202}
203
204fn row_hash<T: serde::Serialize>(value: &T) -> Result<String> {
205 let payload = serde_json::to_vec(value)?;
206 Ok(blake3::hash(&payload).to_hex().to_string())
207}
208
209fn replace_node_properties(
210 conn: &libsql::Connection,
211 rt: &tokio::runtime::Runtime,
212 node_id: &str,
213 properties: &BTreeMap<String, String>,
214) -> Result<()> {
215 block_on(rt, async {
216 conn.execute(
217 "DELETE FROM graph_node_properties WHERE node_id = ?1",
218 [node_id],
219 )
220 .await?;
221 let stmt = conn
222 .prepare("INSERT INTO graph_node_properties (node_id, key, value) VALUES (?1, ?2, ?3)")
223 .await?;
224 for (key, value) in properties {
225 stmt.execute(libsql::params![
226 node_id.to_string(),
227 key.clone(),
228 value.clone()
229 ])
230 .await?;
231 }
232 Ok::<(), anyhow::Error>(())
233 })?;
234 Ok(())
235}
236
237fn replace_edge_properties(
238 conn: &libsql::Connection,
239 rt: &tokio::runtime::Runtime,
240 edge_key: &str,
241 properties: &BTreeMap<String, String>,
242) -> Result<()> {
243 block_on(rt, async {
244 conn.execute(
245 "DELETE FROM graph_edge_properties WHERE edge_key = ?1",
246 [edge_key.to_string()],
247 )
248 .await?;
249 let stmt = conn
250 .prepare("INSERT INTO graph_edge_properties (edge_key, key, value) VALUES (?1, ?2, ?3)")
251 .await?;
252 for (key, value) in properties {
253 stmt.execute(libsql::params![
254 edge_key.to_string(),
255 key.clone(),
256 value.clone()
257 ])
258 .await?;
259 }
260 Ok::<(), anyhow::Error>(())
261 })?;
262 Ok(())
263}
264
265impl GraphStore for LibsqlGraphStore {
266 fn upsert_node(&self, node: &GraphNode) -> Result<()> {
267 let id = node.id.clone();
268 block_on(&self.rt, async {
269 let properties_json = to_json(&node.properties)?;
270 let provenance_json = to_json(&node.provenance)?;
271 let freshness_json = optional_to_json(&node.freshness)?;
272 let hash = row_hash(node)?;
273 self.conn.execute(
274 r#"
275 INSERT INTO graph_nodes
276 (id, kind, label, properties_json, provenance_json, freshness_json, row_hash, source_watermark)
277 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
278 ON CONFLICT(id) DO UPDATE SET
279 kind = excluded.kind,
280 label = excluded.label,
281 properties_json = excluded.properties_json,
282 provenance_json = excluded.provenance_json,
283 freshness_json = excluded.freshness_json,
284 row_hash = excluded.row_hash,
285 source_watermark = excluded.source_watermark
286 "#,
287 libsql::params![node.id.clone(), node.kind.clone(), node.label.clone(), properties_json, provenance_json, freshness_json, hash],
288 ).await?;
289 Ok::<(), anyhow::Error>(())
290 })?;
291 replace_node_properties(&self.conn, &self.rt, &id, &node.properties)?;
292 Ok(())
293 }
294
295 fn upsert_edge(&self, edge: &GraphEdge) -> Result<()> {
296 let edge_key = if edge.id.is_empty() {
297 stable_graph_edge_id(&edge.from_id, &edge.to_id, &edge.kind)
298 } else {
299 edge.id.clone()
300 };
301 let edge_key_for_props = edge_key.clone();
302 block_on(&self.rt, async {
303 let properties_json = to_json(&edge.properties)?;
304 let provenance_json = to_json(&edge.provenance)?;
305 let freshness_json = optional_to_json(&edge.freshness)?;
306 let hash = row_hash(edge)?;
307 self.conn.execute(
308 r#"
309 INSERT INTO graph_edges
310 (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json, row_hash, source_watermark)
311 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL)
312 ON CONFLICT(from_id, to_id, kind) DO UPDATE SET
313 edge_key = excluded.edge_key,
314 properties_json = excluded.properties_json,
315 provenance_json = excluded.provenance_json,
316 freshness_json = excluded.freshness_json,
317 row_hash = excluded.row_hash,
318 source_watermark = excluded.source_watermark
319 "#,
320 libsql::params![edge_key, edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone(), properties_json, provenance_json, freshness_json, hash],
321 ).await?;
322 Ok::<(), anyhow::Error>(())
323 })?;
324 replace_edge_properties(&self.conn, &self.rt, &edge_key_for_props, &edge.properties)?;
325 Ok(())
326 }
327
328 fn delete_node(&self, id: &str) -> Result<usize> {
329 let count = block_on(&self.rt, async {
330 let result = self
331 .conn
332 .execute("DELETE FROM graph_nodes WHERE id = ?1", [id])
333 .await?;
334 Ok::<u64, anyhow::Error>(result)
335 })?;
336 Ok(count as usize)
337 }
338
339 fn delete_edge(&self, from_id: &str, to_id: &str, kind: &str) -> Result<usize> {
340 let count = block_on(&self.rt, async {
341 let result = self
342 .conn
343 .execute(
344 "DELETE FROM graph_edges WHERE from_id = ?1 AND to_id = ?2 AND kind = ?3",
345 libsql::params![from_id, to_id, kind],
346 )
347 .await?;
348 Ok::<u64, anyhow::Error>(result)
349 })?;
350 Ok(count as usize)
351 }
352
353 fn node(&self, id: &str) -> Result<Option<GraphNode>> {
354 block_on(&self.rt, async {
355 let mut rows = self
356 .conn
357 .query(
358 r#"
359 SELECT id, kind, label, properties_json, provenance_json, freshness_json
360 FROM graph_nodes
361 WHERE id = ?1
362 "#,
363 [id],
364 )
365 .await?;
366 match rows.next().await? {
367 Some(row) => Ok(Some(node_from_row(&row)?)),
368 None => Ok(None),
369 }
370 })
371 }
372
373 fn all_nodes(&self) -> Result<Vec<GraphNode>> {
374 block_on(&self.rt, async {
375 let mut rows = self
376 .conn
377 .query(
378 r#"
379 SELECT id, kind, label, properties_json, provenance_json, freshness_json
380 FROM graph_nodes
381 ORDER BY id
382 "#,
383 (),
384 )
385 .await?;
386 let mut nodes = Vec::new();
387 while let Some(row) = rows.next().await? {
388 nodes.push(node_from_row(&row)?);
389 }
390 Ok(nodes)
391 })
392 }
393
394 fn all_edges(&self) -> Result<Vec<GraphEdge>> {
395 block_on(&self.rt, async {
396 let mut rows = self.conn.query(
397 r#"
398 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
399 FROM graph_edges
400 ORDER BY from_id, kind, to_id
401 "#,
402 (),
403 ).await?;
404 let mut edges = Vec::new();
405 while let Some(row) = rows.next().await? {
406 edges.push(edge_from_row(&row)?);
407 }
408 Ok(edges)
409 })
410 }
411
412 fn edge(&self, edge_id: &str) -> Result<Option<GraphEdge>> {
413 block_on(&self.rt, async {
414 let mut rows = self
415 .conn
416 .query(
417 r#"
418 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
419 FROM graph_edges
420 WHERE edge_key = ?1
421 "#,
422 [edge_id],
423 )
424 .await?;
425 match rows.next().await? {
426 Some(row) => Ok(Some(edge_from_row(&row)?)),
427 None => Ok(None),
428 }
429 })
430 }
431
432 fn graph_counts(&self) -> Result<(usize, usize)> {
433 let nodes = block_on(&self.rt, async {
434 let mut rows = self
435 .conn
436 .query("SELECT COUNT(*) FROM graph_nodes", ())
437 .await?;
438 let row = rows.next().await?.context("no row")?;
439 let count: u64 = row.get(0)?;
440 Ok::<u64, anyhow::Error>(count)
441 })?;
442 let edges = block_on(&self.rt, async {
443 let mut rows = self
444 .conn
445 .query("SELECT COUNT(*) FROM graph_edges", ())
446 .await?;
447 let row = rows.next().await?.context("no row")?;
448 let count: u64 = row.get(0)?;
449 Ok::<u64, anyhow::Error>(count)
450 })?;
451 Ok((nodes as usize, edges as usize))
452 }
453
454 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<GraphNode>> {
455 block_on(&self.rt, async {
456 let mut rows = self
457 .conn
458 .query(
459 r#"
460 SELECT id, kind, label, properties_json, provenance_json, freshness_json
461 FROM graph_nodes
462 WHERE kind = ?1
463 ORDER BY id
464 "#,
465 [kind],
466 )
467 .await?;
468 let mut nodes = Vec::new();
469 while let Some(row) = rows.next().await? {
470 nodes.push(node_from_row(&row)?);
471 }
472 Ok(nodes)
473 })
474 }
475
476 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<GraphEdge>> {
477 block_on(&self.rt, async {
478 let mut edges = Vec::new();
479 match kind {
480 Some(kind) => {
481 let mut rows = self.conn.query(
482 r#"
483 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
484 FROM graph_edges
485 WHERE from_id = ?1 AND kind = ?2
486 ORDER BY to_id, kind
487 "#,
488 libsql::params![from_id, kind],
489 ).await?;
490 while let Some(row) = rows.next().await? {
491 edges.push(edge_from_row(&row)?);
492 }
493 }
494 None => {
495 let mut rows = self.conn.query(
496 r#"
497 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
498 FROM graph_edges
499 WHERE from_id = ?1
500 ORDER BY to_id, kind
501 "#,
502 [from_id],
503 ).await?;
504 while let Some(row) = rows.next().await? {
505 edges.push(edge_from_row(&row)?);
506 }
507 }
508 }
509 Ok(edges)
510 })
511 }
512
513 fn incident_edges(&self, node_id: &str, kind: Option<&str>) -> Result<Vec<GraphEdge>> {
514 block_on(&self.rt, async {
515 let sql = match kind {
516 Some(_) => r#"
517 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
518 FROM (
519 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
520 FROM graph_edges
521 WHERE from_id = ?1 AND kind = ?2
522 UNION
523 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
524 FROM graph_edges
525 WHERE to_id = ?1 AND kind = ?2
526 ) e
527 ORDER BY e.edge_key
528 "#,
529 None => r#"
530 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
531 FROM (
532 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
533 FROM graph_edges
534 WHERE from_id = ?1
535 UNION
536 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
537 FROM graph_edges
538 WHERE to_id = ?1
539 ) e
540 ORDER BY e.edge_key
541 "#,
542 };
543 let mut edges = Vec::new();
544 match kind {
545 Some(kind) => {
546 let mut rows = self.conn.query(
547 sql,
548 libsql::params![node_id, kind],
549 ).await?;
550 while let Some(row) = rows.next().await? {
551 edges.push(edge_from_row(&row)?);
552 }
553 }
554 None => {
555 let mut rows = self.conn.query(sql, [node_id]).await?;
556 while let Some(row) = rows.next().await? {
557 edges.push(edge_from_row(&row)?);
558 }
559 }
560 }
561 Ok(edges)
562 })
563 }
564
565 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<GraphEdge>> {
566 if node_ids.is_empty() {
567 return Ok(Vec::new());
568 }
569 block_on(&self.rt, async {
570 self.conn
571 .execute_batch(
572 r#"
573 CREATE TEMP TABLE IF NOT EXISTS _edges_between_ids (id TEXT PRIMARY KEY);
574 DELETE FROM _edges_between_ids;
575 "#,
576 )
577 .await?;
578 for chunk in node_ids.iter().collect::<Vec<_>>().chunks(450) {
579 let row_placeholders: Vec<String> =
580 chunk.iter().map(|_| "(?)".to_string()).collect();
581 let placeholders = row_placeholders.join(", ");
582 let sql = format!(
583 "INSERT OR IGNORE INTO _edges_between_ids (id) VALUES {placeholders}"
584 );
585 let values: Vec<String> = chunk.iter().map(|id| (*id).clone()).collect();
586 self.conn
587 .execute(
588 &sql,
589 libsql::params_from_iter(values.iter().map(|v| v.as_str())),
590 )
591 .await?;
592 }
593 let mut rows = self
594 .conn
595 .query(
596 r#"
597 SELECT e.edge_key, e.from_id, e.to_id, e.kind, e.properties_json, e.provenance_json, e.freshness_json
598 FROM graph_edges e
599 WHERE EXISTS (SELECT 1 FROM _edges_between_ids f WHERE f.id = e.from_id)
600 AND EXISTS (SELECT 1 FROM _edges_between_ids t WHERE t.id = e.to_id)
601 ORDER BY e.from_id, e.kind, e.to_id
602 "#,
603 (),
604 )
605 .await?;
606 let mut edges = Vec::new();
607 while let Some(row) = rows.next().await? {
608 edges.push(edge_from_row(&row)?);
609 }
610 Ok::<Vec<GraphEdge>, anyhow::Error>(edges)
611 })
612 }
613
614 fn shortest_path(
615 &self,
616 from_id: &str,
617 to_id: &str,
618 kind: Option<&str>,
619 ) -> Result<Option<GraphPath>> {
620 self.shortest_path_with_max_hops(from_id, to_id, kind, None)
621 }
622
623 fn shortest_path_with_max_hops(
624 &self,
625 from_id: &str,
626 to_id: &str,
627 kind: Option<&str>,
628 max_hops: Option<usize>,
629 ) -> Result<Option<GraphPath>> {
630 if from_id == to_id {
631 return Ok(Some(GraphPath {
632 nodes: vec![from_id.to_string()],
633 hops: 0,
634 }));
635 }
636 let hop_limit = max_hops.unwrap_or(usize::MAX);
637 if hop_limit == 0 {
638 return Ok(None);
639 }
640
641 let mut visited = BTreeSet::from([from_id.to_string()]);
642 let mut parent = BTreeMap::<String, String>::from([(from_id.to_string(), String::new())]);
643 let mut frontier = vec![from_id.to_string()];
644
645 for _depth in 0..hop_limit {
646 if frontier.is_empty() {
647 break;
648 }
649 let mut next_frontier = BTreeSet::new();
650 for current in &frontier {
651 let neighbors = self.outgoing_edges(current, kind)?;
652 for edge in neighbors {
653 if !visited.insert(edge.to_id.clone()) {
654 continue;
655 }
656 parent.insert(edge.to_id.clone(), current.clone());
657 if edge.to_id == to_id {
658 let mut nodes = vec![to_id.to_string()];
659 let mut cursor = to_id;
660 while let Some(previous) = parent.get(cursor) {
661 if previous.is_empty() {
662 break;
663 }
664 nodes.push(previous.clone());
665 cursor = previous;
666 }
667 nodes.reverse();
668 return Ok(Some(GraphPath {
669 hops: nodes.len().saturating_sub(1),
670 nodes,
671 }));
672 }
673 next_frontier.insert(edge.to_id);
674 }
675 }
676 frontier = next_frontier.into_iter().collect();
677 }
678 Ok(None)
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use tsift_core::{GraphFreshness, GraphProjection, GraphProvenance};
686
687 fn sample_provenance() -> GraphProvenance {
688 GraphProvenance::new("fixture", "src/lib.rs:1").with_content_hash("hash-1")
689 }
690
691 fn sample_projection() -> GraphProjection {
692 let source = sample_provenance();
693 GraphProjection {
694 nodes: vec![
695 GraphNode::new("doc:livekit", "document", "LiveKit guide")
696 .with_property("domain", "livekit")
697 .with_provenance(source.clone())
698 .with_freshness(GraphFreshness::content_hash("node-hash")),
699 GraphNode::new("topic:rooms", "topic", "Rooms"),
700 GraphNode::new("topic:egress", "topic", "Egress"),
701 ],
702 edges: vec![
703 GraphEdge::new("doc:livekit", "topic:rooms", "mentions")
704 .with_property("confidence", "0.91")
705 .with_provenance(source.clone())
706 .with_freshness(GraphFreshness::content_hash("edge-hash")),
707 GraphEdge::new("topic:rooms", "topic:egress", "related_to").with_provenance(source),
708 ],
709 }
710 }
711
712 #[test]
713 fn libsql_store_round_trips_generic_nodes_edges() {
714 let store = LibsqlGraphStore::in_memory().unwrap();
715 let source = sample_provenance();
716 let node = GraphNode::new("doc:livekit", "document", "LiveKit guide")
717 .with_property("domain", "livekit")
718 .with_provenance(source.clone())
719 .with_freshness(GraphFreshness::content_hash("node-hash"));
720 let topic = GraphNode::new("topic:rooms", "topic", "Rooms");
721 let edge = GraphEdge::new("doc:livekit", "topic:rooms", "mentions")
722 .with_property("confidence", "0.91")
723 .with_provenance(source)
724 .with_freshness(GraphFreshness::content_hash("edge-hash"));
725
726 store.upsert_node(&node).unwrap();
727 store.upsert_node(&topic).unwrap();
728 store.upsert_edge(&edge).unwrap();
729
730 assert_eq!(store.node("doc:livekit").unwrap(), Some(node));
731 assert_eq!(store.nodes_by_kind("topic").unwrap(), vec![topic]);
732 assert_eq!(store.all_nodes().unwrap().len(), 2);
733 assert_eq!(store.all_edges().unwrap().len(), 1);
734 assert_eq!(
735 store
736 .outgoing_edges("doc:livekit", Some("mentions"))
737 .unwrap(),
738 vec![edge]
739 );
740 }
741
742 #[test]
743 fn libsql_store_supports_projection_upsert() {
744 let store = LibsqlGraphStore::in_memory().unwrap();
745 let projection = sample_projection();
746 projection.upsert_into(&store).unwrap();
747
748 assert_eq!(store.node("doc:livekit").unwrap().unwrap().kind, "document");
749 assert_eq!(store.nodes_by_kind("topic").unwrap().len(), 2);
750 let mentions = store
751 .outgoing_edges("doc:livekit", Some("mentions"))
752 .unwrap();
753 assert_eq!(mentions.len(), 1);
754 assert_eq!(mentions[0].to_id, "topic:rooms");
755 }
756
757 #[test]
758 fn libsql_store_crud_neighborhood_and_ordering() {
759 let store = LibsqlGraphStore::in_memory().unwrap();
760 let projection = sample_projection();
761 projection.upsert_into(&store).unwrap();
762
763 let neighborhood = store.neighborhood("doc:livekit", 2, None).unwrap().unwrap();
764 let node_ids: Vec<&str> = neighborhood.nodes.iter().map(|n| n.id.as_str()).collect();
765 assert_eq!(node_ids, vec!["doc:livekit", "topic:egress", "topic:rooms"]);
766
767 assert_eq!(
768 store
769 .delete_edge("topic:rooms", "topic:egress", "related_to")
770 .unwrap(),
771 1
772 );
773 assert!(
774 store
775 .shortest_path("doc:livekit", "topic:egress", None)
776 .unwrap()
777 .is_none()
778 );
779 assert_eq!(store.delete_node("topic:rooms").unwrap(), 1);
780 assert!(store.node("topic:rooms").unwrap().is_none());
781 assert!(
782 store
783 .outgoing_edges("doc:livekit", None)
784 .unwrap()
785 .is_empty()
786 );
787 }
788
789 #[test]
790 fn libsql_store_shortest_path() {
791 let store = LibsqlGraphStore::in_memory().unwrap();
792 for id in ["a", "b", "c"] {
793 store
794 .upsert_node(&GraphNode::new(id, "symbol", id))
795 .unwrap();
796 }
797 store
798 .upsert_edge(&GraphEdge::new("a", "b", "calls"))
799 .unwrap();
800 store
801 .upsert_edge(&GraphEdge::new("a", "c", "documents"))
802 .unwrap();
803 store
804 .upsert_edge(&GraphEdge::new("b", "c", "calls"))
805 .unwrap();
806
807 let calls = store.outgoing_edges("a", Some("calls")).unwrap();
808 assert_eq!(calls.len(), 1);
809 assert_eq!(calls[0].to_id, "b");
810
811 let path = store
812 .shortest_path("a", "c", Some("calls"))
813 .unwrap()
814 .unwrap();
815 assert_eq!(path.nodes, vec!["a", "b", "c"]);
816 assert_eq!(path.hops, 2);
817
818 assert!(
819 store
820 .shortest_path("c", "a", Some("calls"))
821 .unwrap()
822 .is_none()
823 );
824 }
825
826 #[test]
827 fn libsql_store_open_creates_db_file() {
828 let dir = tempfile::tempdir().unwrap();
829 let db_path = dir.path().join("test-graph.db");
830 let store = LibsqlGraphStore::open(&db_path).unwrap();
831 store
832 .upsert_node(&GraphNode::new("test", "test", "test"))
833 .unwrap();
834 assert!(db_path.exists());
835 }
836
837 #[test]
838 fn libsql_graph_counts() {
839 let store = LibsqlGraphStore::in_memory().unwrap();
840 for id in ["a", "b", "c"] {
841 store
842 .upsert_node(&GraphNode::new(id, "symbol", id))
843 .unwrap();
844 }
845 store
846 .upsert_edge(&GraphEdge::new("a", "b", "calls"))
847 .unwrap();
848 store
849 .upsert_edge(&GraphEdge::new("b", "c", "calls"))
850 .unwrap();
851 let (nodes, edges) = store.graph_counts().unwrap();
852 assert_eq!(nodes, 3);
853 assert_eq!(edges, 2);
854 }
855
856 #[test]
857 fn libsql_incident_edges_pushdown() {
858 let store = LibsqlGraphStore::in_memory().unwrap();
859 for id in ["a", "b", "c", "d"] {
860 store
861 .upsert_node(&GraphNode::new(id, "symbol", id))
862 .unwrap();
863 }
864 store
865 .upsert_edge(&GraphEdge::new("a", "b", "calls"))
866 .unwrap();
867 store
868 .upsert_edge(&GraphEdge::new("a", "c", "documents"))
869 .unwrap();
870 store
871 .upsert_edge(&GraphEdge::new("d", "b", "calls"))
872 .unwrap();
873 store
874 .upsert_edge(&GraphEdge::new("c", "b", "references"))
875 .unwrap();
876
877 let all_incident = store.incident_edges("b", None).unwrap();
878 assert_eq!(all_incident.len(), 3);
879
880 let calls_incident = store.incident_edges("b", Some("calls")).unwrap();
881 assert_eq!(calls_incident.len(), 2);
882 assert!(calls_incident.iter().all(|e| e.kind == "calls"));
883
884 let docs_incident = store.incident_edges("b", Some("documents")).unwrap();
885 assert!(docs_incident.is_empty());
886
887 let a_incident = store.incident_edges("a", None).unwrap();
888 assert_eq!(a_incident.len(), 2);
889 assert!(a_incident.iter().all(|e| e.from_id == "a"));
890
891 let d_incident = store.incident_edges("d", None).unwrap();
892 assert_eq!(d_incident.len(), 1);
893 assert_eq!(d_incident[0].to_id, "b");
894 }
895
896 #[test]
897 fn libsql_store_edges_between_nodes_pushdown() {
898 let store = LibsqlGraphStore::in_memory().unwrap();
899 for id in ["a", "b", "c", "outside"] {
900 store
901 .upsert_node(&GraphNode::new(id, "symbol", id))
902 .unwrap();
903 }
904 for edge in [
905 GraphEdge::new("a", "b", "calls"),
906 GraphEdge::new("b", "c", "calls"),
907 GraphEdge::new("a", "outside", "calls"),
908 GraphEdge::new("outside", "c", "calls"),
909 ] {
910 store.upsert_edge(&edge).unwrap();
911 }
912
913 let scoped = ["a".to_string(), "b".to_string(), "c".to_string()]
914 .into_iter()
915 .collect::<BTreeSet<_>>();
916 let edge_keys = store
917 .edges_between_nodes(&scoped)
918 .unwrap()
919 .into_iter()
920 .map(|edge| (edge.from_id, edge.kind, edge.to_id))
921 .collect::<Vec<_>>();
922
923 assert_eq!(
924 edge_keys,
925 vec![
926 ("a".to_string(), "calls".to_string(), "b".to_string()),
927 ("b".to_string(), "calls".to_string(), "c".to_string()),
928 ]
929 );
930
931 let empty: BTreeSet<String> = BTreeSet::new();
932 assert!(store.edges_between_nodes(&empty).unwrap().is_empty());
933
934 let single = ["a".to_string()].into_iter().collect::<BTreeSet<_>>();
935 assert!(store.edges_between_nodes(&single).unwrap().is_empty());
936 }
937}