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 nodes_by_kind(&self, kind: &str) -> Result<Vec<GraphNode>> {
413 block_on(&self.rt, async {
414 let mut rows = self
415 .conn
416 .query(
417 r#"
418 SELECT id, kind, label, properties_json, provenance_json, freshness_json
419 FROM graph_nodes
420 WHERE kind = ?1
421 ORDER BY id
422 "#,
423 [kind],
424 )
425 .await?;
426 let mut nodes = Vec::new();
427 while let Some(row) = rows.next().await? {
428 nodes.push(node_from_row(&row)?);
429 }
430 Ok(nodes)
431 })
432 }
433
434 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<GraphEdge>> {
435 block_on(&self.rt, async {
436 let mut edges = Vec::new();
437 match kind {
438 Some(kind) => {
439 let mut rows = self.conn.query(
440 r#"
441 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
442 FROM graph_edges
443 WHERE from_id = ?1 AND kind = ?2
444 ORDER BY to_id, kind
445 "#,
446 libsql::params![from_id, kind],
447 ).await?;
448 while let Some(row) = rows.next().await? {
449 edges.push(edge_from_row(&row)?);
450 }
451 }
452 None => {
453 let mut rows = self.conn.query(
454 r#"
455 SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json
456 FROM graph_edges
457 WHERE from_id = ?1
458 ORDER BY to_id, kind
459 "#,
460 [from_id],
461 ).await?;
462 while let Some(row) = rows.next().await? {
463 edges.push(edge_from_row(&row)?);
464 }
465 }
466 }
467 Ok(edges)
468 })
469 }
470
471 fn shortest_path(
472 &self,
473 from_id: &str,
474 to_id: &str,
475 kind: Option<&str>,
476 ) -> Result<Option<GraphPath>> {
477 self.shortest_path_with_max_hops(from_id, to_id, kind, None)
478 }
479
480 fn shortest_path_with_max_hops(
481 &self,
482 from_id: &str,
483 to_id: &str,
484 kind: Option<&str>,
485 max_hops: Option<usize>,
486 ) -> Result<Option<GraphPath>> {
487 if from_id == to_id {
488 return Ok(Some(GraphPath {
489 nodes: vec![from_id.to_string()],
490 hops: 0,
491 }));
492 }
493 let hop_limit = max_hops.unwrap_or(usize::MAX);
494 if hop_limit == 0 {
495 return Ok(None);
496 }
497
498 let mut visited = BTreeSet::from([from_id.to_string()]);
499 let mut parent = BTreeMap::<String, String>::from([(from_id.to_string(), String::new())]);
500 let mut frontier = vec![from_id.to_string()];
501
502 for _depth in 0..hop_limit {
503 if frontier.is_empty() {
504 break;
505 }
506 let mut next_frontier = BTreeSet::new();
507 for current in &frontier {
508 let neighbors = self.outgoing_edges(current, kind)?;
509 for edge in neighbors {
510 if !visited.insert(edge.to_id.clone()) {
511 continue;
512 }
513 parent.insert(edge.to_id.clone(), current.clone());
514 if edge.to_id == to_id {
515 let mut nodes = vec![to_id.to_string()];
516 let mut cursor = to_id;
517 while let Some(previous) = parent.get(cursor) {
518 if previous.is_empty() {
519 break;
520 }
521 nodes.push(previous.clone());
522 cursor = previous;
523 }
524 nodes.reverse();
525 return Ok(Some(GraphPath {
526 hops: nodes.len().saturating_sub(1),
527 nodes,
528 }));
529 }
530 next_frontier.insert(edge.to_id);
531 }
532 }
533 frontier = next_frontier.into_iter().collect();
534 }
535 Ok(None)
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use tsift_core::{GraphFreshness, GraphProjection, GraphProvenance};
543
544 fn sample_provenance() -> GraphProvenance {
545 GraphProvenance::new("fixture", "src/lib.rs:1").with_content_hash("hash-1")
546 }
547
548 fn sample_projection() -> GraphProjection {
549 let source = sample_provenance();
550 GraphProjection {
551 nodes: vec![
552 GraphNode::new("doc:livekit", "document", "LiveKit guide")
553 .with_property("domain", "livekit")
554 .with_provenance(source.clone())
555 .with_freshness(GraphFreshness::content_hash("node-hash")),
556 GraphNode::new("topic:rooms", "topic", "Rooms"),
557 GraphNode::new("topic:egress", "topic", "Egress"),
558 ],
559 edges: vec![
560 GraphEdge::new("doc:livekit", "topic:rooms", "mentions")
561 .with_property("confidence", "0.91")
562 .with_provenance(source.clone())
563 .with_freshness(GraphFreshness::content_hash("edge-hash")),
564 GraphEdge::new("topic:rooms", "topic:egress", "related_to").with_provenance(source),
565 ],
566 }
567 }
568
569 #[test]
570 fn libsql_store_round_trips_generic_nodes_edges() {
571 let store = LibsqlGraphStore::in_memory().unwrap();
572 let source = sample_provenance();
573 let node = GraphNode::new("doc:livekit", "document", "LiveKit guide")
574 .with_property("domain", "livekit")
575 .with_provenance(source.clone())
576 .with_freshness(GraphFreshness::content_hash("node-hash"));
577 let topic = GraphNode::new("topic:rooms", "topic", "Rooms");
578 let edge = GraphEdge::new("doc:livekit", "topic:rooms", "mentions")
579 .with_property("confidence", "0.91")
580 .with_provenance(source)
581 .with_freshness(GraphFreshness::content_hash("edge-hash"));
582
583 store.upsert_node(&node).unwrap();
584 store.upsert_node(&topic).unwrap();
585 store.upsert_edge(&edge).unwrap();
586
587 assert_eq!(store.node("doc:livekit").unwrap(), Some(node));
588 assert_eq!(store.nodes_by_kind("topic").unwrap(), vec![topic]);
589 assert_eq!(store.all_nodes().unwrap().len(), 2);
590 assert_eq!(store.all_edges().unwrap().len(), 1);
591 assert_eq!(
592 store
593 .outgoing_edges("doc:livekit", Some("mentions"))
594 .unwrap(),
595 vec![edge]
596 );
597 }
598
599 #[test]
600 fn libsql_store_supports_projection_upsert() {
601 let store = LibsqlGraphStore::in_memory().unwrap();
602 let projection = sample_projection();
603 projection.upsert_into(&store).unwrap();
604
605 assert_eq!(store.node("doc:livekit").unwrap().unwrap().kind, "document");
606 assert_eq!(store.nodes_by_kind("topic").unwrap().len(), 2);
607 let mentions = store
608 .outgoing_edges("doc:livekit", Some("mentions"))
609 .unwrap();
610 assert_eq!(mentions.len(), 1);
611 assert_eq!(mentions[0].to_id, "topic:rooms");
612 }
613
614 #[test]
615 fn libsql_store_crud_neighborhood_and_ordering() {
616 let store = LibsqlGraphStore::in_memory().unwrap();
617 let projection = sample_projection();
618 projection.upsert_into(&store).unwrap();
619
620 let neighborhood = store.neighborhood("doc:livekit", 2, None).unwrap().unwrap();
621 let node_ids: Vec<&str> = neighborhood.nodes.iter().map(|n| n.id.as_str()).collect();
622 assert_eq!(node_ids, vec!["doc:livekit", "topic:egress", "topic:rooms"]);
623
624 assert_eq!(
625 store
626 .delete_edge("topic:rooms", "topic:egress", "related_to")
627 .unwrap(),
628 1
629 );
630 assert!(
631 store
632 .shortest_path("doc:livekit", "topic:egress", None)
633 .unwrap()
634 .is_none()
635 );
636 assert_eq!(store.delete_node("topic:rooms").unwrap(), 1);
637 assert!(store.node("topic:rooms").unwrap().is_none());
638 assert!(
639 store
640 .outgoing_edges("doc:livekit", None)
641 .unwrap()
642 .is_empty()
643 );
644 }
645
646 #[test]
647 fn libsql_store_shortest_path() {
648 let store = LibsqlGraphStore::in_memory().unwrap();
649 for id in ["a", "b", "c"] {
650 store
651 .upsert_node(&GraphNode::new(id, "symbol", id))
652 .unwrap();
653 }
654 store
655 .upsert_edge(&GraphEdge::new("a", "b", "calls"))
656 .unwrap();
657 store
658 .upsert_edge(&GraphEdge::new("a", "c", "documents"))
659 .unwrap();
660 store
661 .upsert_edge(&GraphEdge::new("b", "c", "calls"))
662 .unwrap();
663
664 let calls = store.outgoing_edges("a", Some("calls")).unwrap();
665 assert_eq!(calls.len(), 1);
666 assert_eq!(calls[0].to_id, "b");
667
668 let path = store
669 .shortest_path("a", "c", Some("calls"))
670 .unwrap()
671 .unwrap();
672 assert_eq!(path.nodes, vec!["a", "b", "c"]);
673 assert_eq!(path.hops, 2);
674
675 assert!(
676 store
677 .shortest_path("c", "a", Some("calls"))
678 .unwrap()
679 .is_none()
680 );
681 }
682
683 #[test]
684 fn libsql_store_open_creates_db_file() {
685 let dir = tempfile::tempdir().unwrap();
686 let db_path = dir.path().join("test-graph.db");
687 let store = LibsqlGraphStore::open(&db_path).unwrap();
688 store
689 .upsert_node(&GraphNode::new("test", "test", "test"))
690 .unwrap();
691 assert!(db_path.exists());
692 }
693
694 #[test]
695 fn libsql_graph_counts() {
696 let store = LibsqlGraphStore::in_memory().unwrap();
697 for id in ["a", "b", "c"] {
698 store
699 .upsert_node(&GraphNode::new(id, "symbol", id))
700 .unwrap();
701 }
702 store
703 .upsert_edge(&GraphEdge::new("a", "b", "calls"))
704 .unwrap();
705 store
706 .upsert_edge(&GraphEdge::new("b", "c", "calls"))
707 .unwrap();
708 let (nodes, edges) = store.graph_counts().unwrap();
709 assert_eq!(nodes, 3);
710 assert_eq!(edges, 2);
711 }
712}