Gunnar

nordisk/knut

.nornir/graphdb-guide.md

<!-- nornir:class: guide -->
# Using `knut-graphdb`

> **Class:** `guide`. How to read a graph endpoint, and how to write a backend.
> **Source:** `crates/knut-graphdb/`.

## Read a FalkorDB

```rust
use knut_graphdb::{FalkorBackend, GraphDb};

let db = FalkorBackend::new("127.0.0.1:6379");   // or "redis://…" / "rediss://…"

for name in db.graph_names().await? {            // every graph on the endpoint
    let schema = db.open(&name).await?;          // labels, rel types, keys, counts
    let scene  = db.read(&name, 300).await?;     // nodes + edges, capped
    println!("{name}: {} labels, {} nodes", schema.labels.len(), scene.nodes.len());
}
```

`FalkorBackend::new` takes an **endpoint and nothing else**. A bare `host:port` is
normalised to `redis://host:port`; a string that already carries a scheme is passed
through untouched, so a `rediss://` endpoint is never downgraded to plaintext.

Two knobs, both optional:

```rust
let db = FalkorBackend::new("rediss://falkor.internal:6379")
    .with_identity(tls)                          // mutual-TLS client identity
    .with_timeout(Duration::from_secs(5));       // per-op bound
```

The identity is a **credential, not a lifecycle knob** — it changes how the backend
dials, never whether the endpoint exists. Its bytes are never logged: `Debug` prints
`mtls: true` and nothing else.

The default bound is `KORP_GRAPH_READ_TIMEOUT_MS` or 30 s, and it can never be zero —
a parse to 0 falls back to the default, so the bound always fires.

## Read an embedded Cozo (no container)

```rust
use knut_graphdb::{CozoBackend, GraphDb};

let db = CozoBackend::shared()?;         // process-global, demo-seeded once
assert_eq!(db.graph_names().await?, vec!["korp".to_string()]);
let scene = db.read("korp", 0).await?;   // 0 ⇒ DEFAULT_SCENE_LIMIT
```

Cozo speaks **Datalog**, not Cypher. It does not implement `Cypher` at all, so there
is nothing to stub and nothing to panic from. Ask it for a query bar and you get a
value, not a crash:

```rust
if !db.cypher_support().is_available() {
    let refusal = knut_graphdb::no_cypher_here(&db);   // GraphError::NoQueryLanguage
}
```

## Run Cypher (only where it exists)

```rust
use knut_graphdb::{Cypher, FalkorBackend};

let db = FalkorBackend::new("127.0.0.1:6379");
let sub = db.cypher("social", "MATCH (a)-[r]->(b) RETURN …", 300).await?;
```

`limit` is passed to `ensure_limit`, so a query that bounds itself keeps its own cap
and an unbounded one gets `LIMIT <limit>`. **`limit = 0` means "the caller already
bounded this"** and the Cypher reaches the engine verbatim. An empty query falls back
to the Person-anchored `scene_cypher`.

## Telling the four answers apart

```rust
match db.graph_names().await {
    Ok(names) if names.is_empty() => …,          // REACHABLE and empty
    Ok(names)                     => …,
    Err(GraphError::Connect(_))   => …,          // unreachable
    Err(GraphError::Timeout { .. }) => …,        // past the bound
    Err(e)                        => …,
}
```

`db.open("typo")` is `GraphError::NotFound`, never an empty `GraphSchema` — and the
refused open leaves **no stray graph** on the endpoint, because `open` lists before it
probes.

## Write a backend

Implement five methods:

```rust
#[async_trait]
impl GraphDb for MyStore {
    fn kind(&self) -> &'static str { "mystore" }
    fn endpoint(&self) -> &str { &self.url }
    async fn graph_names(&self) -> Result<Vec<String>, GraphError> { … }
    async fn open(&self, graph: &str) -> Result<GraphSchema, GraphError> { … }
    async fn read(&self, graph: &str, limit: usize) -> Result<SubGraph, GraphError> { … }
}
```

That is a complete backend. Three rules:

1. **Never return `Ok(vec![])` for an unreachable endpoint.** Empty and unreachable
   are different answers and callers branch on the difference.
2. **`open` establishes existence from `graph_names` BEFORE it probes**, and a name
   that is not there is `GraphError::NotFound`. If your engine creates a key on first
   touch (FalkorDB does), the other order silently makes every name valid.
3. **Do not implement `Cypher` unless you speak it.** Leaving it out is the honest
   answer and costs you nothing — `cypher_support()` already says `None`.

Add `neighbours` / `shortest_path` / `upsert` if you can serve them; the defaults
refuse with `GraphError::Unsupported` naming your backend and the op.

## Extras that are not on the trait

`FalkorBackend::delete_graph(graph)` (`GRAPH.DELETE`) and
`FalkorBackend::config_get_int(key)` (`GRAPH.CONFIG GET`) are **inherent** methods:
they are FalkorDB server operations, not something every graph backend has. They live
here so a consumer never opens its own redis connection to send one — which is
exactly how korp ended up with three.

## Running the live gates

```bash
podman run -d --name falkordb -p 6379:6379 falkordb/falkordb:latest
KNUT_FALKOR_ENDPOINT=127.0.0.1:6379 \
  cargo test -p knut-graphdb --test live_falkordb -- --ignored --nocapture
```

Every graph they create is namespaced by pid and deleted afterwards, so they run
beside another lane's data on a shared endpoint.