Skip to main content

tsift_libsql/
lib.rs

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}