knut-graphdb — one graph client, folded from two
Class:
design. The trait every consumer reads a graph endpoint through, and what the 2026-09-01 fold of korp'sGraphStoreinto knut'sGraphDbchanged. Source:crates/knut-graphdb/—GraphDb,Cypher,FalkorBackend,CozoBackend.
The sin it fixes
There were two FalkorDB clients, each holding half a seam:
knut GraphDb |
korp GraphStore |
|
| list the graphs on an endpoint | — | graph_names |
| open one for its catalogue | — | open |
| read a scene | query (Cypher in the signature) |
read (no query language) |
| expand a node | neighbours |
— |
| shortest path | shortest_path |
— |
| write rows | upsert |
— |
| colour the result | — | scene + Legend |
Neither was complete, and because graphar_flight::FalkorExecutor knew only GRAPH.QUERY, korp additionally opened three redis::Clients of its own — for GRAPH.LIST, GRAPH.DELETE and GRAPH.CONFIG GET. A FalkorDB read could therefore be issued from four different files with four different error vocabularies.
They are one trait now, and it lives in knut. korp → knut; knut → korp is zero, guarded by crates/knut-graphdb/tests/no_korp_dependency.rs (18 manifests + 269 sources, 3.6 MB, reported).
The trait
kind · endpoint · graph_names · open · read · neighbours · shortest_path · upsert
Five are required (kind, endpoint, graph_names, open, read); the rest are provided and refuse honestly with GraphError::Unsupported. The smallest complete backend is five functions long and contains no stub that can panic.
A
GraphDbis a CLIENT. It takes an endpoint and talks to it. It never starts a process and it never knows containers exist.
Cypher is optional, and it is not on the base trait at all
read(graph, limit) takes a graph name and a row cap. No query language appears anywhere in a base signature. A backend with no query language — a GraphAr file set, a knut projection, an in-memory scene, Cozo — is a complete implementor rather than a degraded one: it has no method to stub, so it has no unimplemented!() to panic from. Cypher is the separate Cypher extension trait plus a CypherSupport advertisement, and no_cypher_here() is the typed refusal a caller holding only a &dyn GraphDb gets.
That claim is checked by the type system, not by a comment. Move cypher onto GraphDb as a required method and CozoBackend stops compiling with E0046.
open() LISTS BEFORE IT PROBES, and that order is load bearing
FalkorDB creates a graph key on the first GRAPH.QUERY against an unknown name. An open that probed first would therefore make the probe itself the reason the answer is "yes": the existence check could never fire, and a typo'd graph name would leave a stray empty graph on the endpoint.
Observed live by korp's graph-trait lane on 2026-08-31 and re-observed on 2026-09-01 while proving the guard red: a probe planted before the check made open("knut_absent_2283864") return Ok(GraphSchema { node_count: 0, .. }) — the blank pane that was really a 404 — and left knut_absent_2283864 in GRAPH.LIST afterwards. It costs one extra round trip per open. Correctness wins.
Empty ≠ unreachable ≠ missing ≠ timed out
Four distinct GraphError variants, never prose:
| answer | type |
| reachable, zero graphs | Ok(vec![]) |
| unreachable endpoint | GraphError::Connect |
| the graph is not there | GraphError::NotFound |
| past the time bound | GraphError::Timeout |
| the backend has no query language | GraphError::NoQueryLanguage |
A backend that folds a connect failure into an empty list is wrong on this seam.
Colour is NOT here, and that is deliberate
A scene's colour legend stayed korp-side (korp::graphstore::GraphScene, built from korp::graphnav::label_rgb). Three reasons, in order of weight:
- It would import an ontology into a base crate. The hues are pinned to korp's own labels —
Person,Vessel,PoliceRecord— which is exactly the dependencyGraphNode::natural_keyexists to avoid: knut hands back the raw key and korp resolves it. A palette keyed by korp's labels is the same cycle wearing a hat. - A second consumer would inherit the wrong palette. knut's other graph consumers (thund, popviz, knut-flight) have their own label vocabularies.
- The legend belongs with the thing that paints it. korp's is built by construction from the labels the 2D
facett_graphnavcanvas and the 3Dfacett_graph3dcloud actually paint, and korp's own test asserts the seam's legend equals the hue the 3D path draws. Moved here, that check could not exist.
What this crate owes a renderer is the labels. The renderer owns the colours.
The backends
| backend | feature | reaches | Cypher |
FalkorBackend |
falkordb (default) |
one FalkorDB / Valkey endpoint, optionally over mutual TLS | yes |
CozoBackend |
cozo |
an embedded CozoDB mem store — no container, no redis, no socket |
no, and it is complete |
CozoBackend became genuinely multi-graph in the fold: its two stored relations are keyed by graph name, so it answers graph_names / open / read the way a real endpoint does. korp gained a container-free graph backend it did not have.
One client, provably
graphar_flight::FalkorExecutor is the only file in the constellation that builds a redis::Client for graph work, and it now carries the whole verb set — GRAPH.QUERY, GRAPH.LIST, GRAPH.DELETE, GRAPH.CONFIG GET — precisely so no caller has to open a connection to send one. That is what the second client was.
korp/tests/graphstore_one_falkor_client.rs scans both repos' production sources (233 files, 8.97 MB, reported) and asserts: korp speaks zero FalkorDB, exactly one file serves the graph seam, and the only other speaker is graphar-falkordb's bulk loader — a different protocol (GRAPH.BULK), named so a third cannot appear silently.
What is NOT folded
graphar-falkordb's bulk loader stays its own client. It speaks the binary GRAPH.BULK insert format, not the query protocol, and it is a writer of whole datasets rather than a reader of scenes. Folding it would put two unrelated protocols behind one type.