Graphs
Graphs are VSKI's built-in property graphs: labelled nodes and directed,
weighted edges stored in the same per-tenant SQLite database as everything
else. There is no separate graph database to run or keep in sync - writes go
to two physical tables (graph_<name>_nodes, graph_<name>_edges) and
traversals are served from a compact in-memory index that reloads from
SQLite on demand.
Graphs are first-class entities alongside collections: same (dbName, name)
namespacing, same rule-based access control, same single-binary deployment.
Use them wherever you need to follow relationships rather than filter
rows: agent memory, knowledge linking, dependency walking, social graphs.
Creating Graphs
Basic Graph
const graph = await client.settings.graphs.create({
name: "knowledge",
options: { directed: true },
});
Graph names follow the collection rules (letters, digits, - and _).
Creating a graph with the name of a previously deleted graph revives it
with its contents intact (soft delete, same as collections).
Options
| Option | Default | Description |
|---|---|---|
directed |
true |
false makes every traversal follow edges both ways |
maxNodes |
server cap | Per-graph node cap (cannot exceed the server-wide cap) |
maxEdges |
server cap | Per-graph edge cap (cannot exceed the server-wide cap) |
Nodes
A node is an id, an optional label, and free-form properties.
Create Nodes
const graph = client.db("mydb").graph("knowledge");
const alice = await graph.createNodes({
id: "alice",
label: "person",
properties: { ref: { collection: "users", recordId: "u1" } },
});
// Bulk create is a single request and returns { items: [...] }
const batch = await graph.createNodes([
{ id: "bob", label: "person" },
{ id: "carol", label: "person" },
{ label: "doc-1" }, // id omitted -> 15-char generated id
]);
A common pattern is storing properties.ref = { collection, recordId } so
the graph indexes relationships between records that live in collections,
while the records themselves stay in their collections.
Upsert (idempotent create)
Re-ingesting the same node id normally errors. Pass upsert: true to
replace it instead - the batch is the primitive for indexing pipelines
(deterministic ids, replay-safe):
// Replace-or-create; label and properties are replaced wholesale
// (it does not merge properties).
await graph.createNodes(
{ id: "day:2026-10-22", label: "day", properties: { date: "2026-10-22" } },
{ upsert: true },
);
Upserts that hit an existing id additionally check the graph's updateRule
(the incoming item already passes createRule like any create). The whole
batch is written in one transaction - on failure nothing is persisted.
Read, Update, Delete
const node = await graph.getNode("alice");
const updated = await graph.updateNode("alice", {
label: "admin",
properties: { role: "admin" }, // replaces the whole object
});
await graph.deleteNode("bob"); // incident edges are deleted too
List and Search
// Page through all nodes
const all = await graph.getNodes({ page: 1, perPage: 50 });
// Exact label filter
const people = await graph.getNodes({ label: "person" });
// Substring search across id and label (case-insensitive, ASCII)
const hits = await graph.getNodes({ q: "ali" });
// Fetch specific ids (max 500)
const pair = await graph.getNodes({ ids: ["alice", "day:2026-10-22"] });
// Filter inside properties (see "Property filters" below)
const docs = await graph.getNodes({ prop: ["ref.collection=documents"] });
q is the one to reach for in search boxes - it matches a substring
anywhere in the node's id or label, which works over 100k-node graphs
without paging everything to the client.
Property filters
prop narrows by values inside the JSON properties (it never touches
the label column or ids). Each filter is a path op value string, with
= != > >= < <= ~ (substring) as operators and dotted paths
into nested objects:
await graph.getNodes({ prop: ["ref.collection=users", "capacity>=5"] });
await graph.getEdges({ prop: ["status!=draft", "title~meeting"] });
// "null" compares against missing/null properties
await graph.getNodes({ prop: ["archived=null"] });
Filters compile to SQLite json_extract comparisons. Numeric-looking
values bind as numbers (capacity>=5 compares numerically); everything
else compares as text. These are full scans - fine at the 100k cap, but
prefer label / ids when those fit.
Edges
An edge is source, target, an optional label, an optional numeric
weight, and free-form properties. Edges are directed (unless the graph
was created with directed: false).
Create Edges
const e1 = await graph.createEdges({
id: "e1",
source: "alice",
target: "bob",
label: "knows",
weight: 1,
});
// Bulk, and ids are optional here as well
await graph.createEdges([
{ source: "bob", target: "carol", label: "knows", weight: 1 },
{ source: "alice", target: "carol", label: "knows", weight: 0.5 },
]);
Creating an edge whose source or target node does not exist fails with
a node not found error - create endpoints first. Edges support
{ upsert: true } exactly like nodes, replacing source, target,
label, weight and properties of an existing id.
Read, Update, Delete
const list = await graph.getEdges({ source: "alice" }); // or target, label
const byLabel = await graph.getEdges({ label: "knows" });
const found = await graph.getEdges({ q: "alice" }); // id, source, target, label
// source/target accept several ids at once (max 500): everything
// touching any of last week's day nodes in one request
const week = await graph.getEdges({
target: ["day:2026-10-19", "day:2026-10-20", "day:2026-10-21"],
prop: ["hours>=1"],
});
const relabeled = await graph.updateEdge("e1", { label: "colleague" });
// weight overwrites; weightDelta adds atomically (server-side, under the
// graph write lock - the classic counter for "hours accumulated on X")
const bumped = await graph.updateEdge("e1", { weightDelta: 0.5 });
await graph.deleteEdge("e1");
weight and weightDelta are mutually exclusive in one request.
Accumulating into JSON properties still means read-modify-write - keep
accumulators in weight (it is also the only numeric the in-memory index
tracks).
Traversal
Traversal reads (neighbors, traverse, path, subgraph) are served
from the in-memory index - a worst-case BFS over a 100k-edge graph runs in
single-digit milliseconds. They are gated by the graph's viewRule.
Neighbors (one hop)
const out = await graph.neighbors("alice", { direction: "out" });
console.log(out.nodes); // [{ id: "bob" }, { id: "carol" }]
console.log(out.edges);
const inbound = await graph.neighbors("alice", { direction: "in" });
const either = await graph.neighbors("alice", {
direction: "both",
labels: ["knows"], // restrict to these edge labels
nodeLabels: ["document"], // restrict to these neighbor node labels
});
nodeLabels filters nodes the same way labels filters edges - handy
when one hop mixes entity types ("documents linked to this day, not the
people or other days").
Traverse (BFS to a depth)
const result = await graph.traverse("alice", {
depth: 3, // max 10
direction: "out",
labels: ["knows"],
nodeLabels: ["person"], // nodes to follow/return
});
console.log(result.nodes); // every node reachable within 3 hops
Path (shortest route between two nodes)
// Fewest hops
const byHops = await graph.path("alice", "dave", { cost: "hops" });
// Cheapest route by edge weight (Dijkstra)
const byWeight = await graph.path("alice", "dave", { cost: "weight" });
// Directed: refuse to walk edges backwards (out = source->target only)
const forward = await graph.path("alice", "dave", { direction: "out" });
console.log(byWeight.found); // true
console.log(byWeight.hops); // 2
console.log(byWeight.cost); // sum of edge weights on the route
console.log(byWeight.nodes); // the route, in order
Two things make path different from neighbors/traverse:
- by default it follows edges in both directions - the route may
traverse an edge backwards. Pass
direction: "out"(or"in") to respect edge direction - unweighted edges count as weight
1, andcostis always the summed weight along the returned route (inhopsmode too)
Subgraph (neighborhood around seed nodes)
const hood = await graph.subgraph(["alice"], { hops: 2, limit: 500 });
console.log(hood.nodes, hood.edges);
hops is clamped to 1-5 and limit to 1000 - the subgraph endpoint is
designed for visualizing a neighborhood, not dumping a whole graph. The
walk follows outbound edges by default; add direction: "both" (or
"in") to include inbound relationships. labels / nodeLabels filter
as everywhere else, and seeds are always included regardless of
nodeLabels.
Stats
const stats = await graph.stats();
console.log(stats.nodeCount); // 4
console.log(stats.edgeCount); // 3
console.log(stats.nodeLabels); // { person: 3, document: 1 }
console.log(stats.edgeLabels); // { knows: 3 }
console.log(stats.edgeWeightSums); // { knows: 7.5 } - sum of weights
// per label (labels without any
// weighted edges are omitted)
console.log(stats.avgDegree); // 1.5
console.log(stats.maxDegree); // 2
console.log(stats.maxNodesCap); // effective caps (min of graph + server)
console.log(stats.maxEdgesCap);
Rules
Graphs use the five collection rule fields with the same semantics and the same expression syntax (Rules and Access Control):
await client.settings.graphs.create({
name: "knowledge",
listRule: "", // empty = admins only (default)
viewRule: "*", // "*" = any authenticated user
createRule: "@request.auth.id != ''", // any logged-in user
updateRule: "",
deleteRule: "",
});
The HTTP API requires a valid token on every endpoint, so "*" means any
authenticated user rather than anonymous internet access.
- Gate rules (
viewRule, and the list gate) are evaluated per request with only@request.auth.*available - this is what opens or closes the traversal endpoints. - Per-item rules (
listRule,createRule,updateRule,deleteRule) are additionally evaluated against each node or edge: itsid,label(source,targetfor edges) andproperties. For examplelistRule: "label = 'public'"lets non-admins list only nodes labelledpublic; when rules filtered items out, the list response carries"filtered": trueand counts only visible items. A rule that references item fields cannot be decided at the gate and defers to this per-item evaluation.
Admins bypass all rules.
Caps and Memory
Server-wide defaults bound every graph: GRAPH_MAX_NODES and
GRAPH_MAX_EDGES (100000 each). A graph may declare lower per-graph caps
via options.maxNodes / options.maxEdges; exceeding a cap is a clean
400 from the write lock, so caps cannot be raced.
The memory story is boring by design: each warm graph's index holds ids,
labels and weights (never properties) - roughly 20-25 MB for a fully warm
100k/100k graph. At most GRAPH_INDEX_MAX_GRAPHS (default 32) graphs stay
warm; cold ones are evicted LRU and reload from SQLite on the next touch.
Replicas revalidate against the database epoch and reload transparently.
Dashboard
Since v0.11 the web dashboard includes full graph support: a Graphs
section in the sidebar, an index page, a create form (rules and caps
included), and an explorer with a searchable center-node picker (backed by
q), hop/limit/direction controls, expand-on-click, a shortest-path
finder, per-label edge colors and node/edge editing.
Python SDK
The Python SDK (vski-sdk >= 0.4.0) mirrors the whole surface:
from vski import VskiClient
async with VskiClient("http://localhost:8347") as client:
graph = client.db("mydb").graph("knowledge")
await graph.create_nodes([
{"id": "alice", "label": "person"},
{"id": "bob", "label": "person"},
])
await graph.create_edges({"source": "alice", "target": "bob", "label": "knows"})
route = await graph.path("alice", "bob")
print(route["hops"])
hits = await graph.get_nodes(q="ali") # substring search (>= 0.4.1)
The v0.13 additions are mirrored as of vski-sdk >= 0.6.0:
await graph.create_nodes(node, upsert=True) # idempotent create
await graph.update_edge(eid, weight_delta=1.5) # atomic accumulate
await graph.get_nodes(prop=["ref.collection=users"], ids=["u:alice"])
await graph.get_edges(target=["day:2026-10-19", "day:2026-10-20"])
await graph.neighbors("day:2026-10-19", node_labels=["document"])
await graph.path("a", "b", direction="out") # respect edge direction
The sync wrapper from vski.sync import SyncVskiClient mirrors it 1:1.
API Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/graphs |
List graphs (all databases) |
POST |
/api/graphs |
Create graph |
GET |
/api/graphs/:name |
Get graph config |
PATCH |
/api/graphs/:name |
Update graph config |
DELETE |
/api/graphs/:name |
Delete graph (soft; data kept) |
GET |
/api/graphs/:name/nodes |
List nodes (label, ids, q, prop, paging) |
POST |
/api/graphs/:name/nodes |
Create node(s) (?upsert=true) |
GET/PATCH/DELETE |
/api/graphs/:name/nodes/:nodeId |
Node read/update/delete |
GET |
/api/graphs/:name/edges |
List edges (source, target, label, q, prop; sources/targets accept comma lists) |
POST |
/api/graphs/:name/edges |
Create edge(s) (?upsert=true) |
GET/PATCH/DELETE |
/api/graphs/:name/edges/:edgeId |
Edge read/update/delete (weightDelta on PATCH) |
GET |
/api/graphs/:name/nodes/:nodeId/neighbors |
One-hop neighbors (direction, labels, nodeLabels, limit) |
GET |
/api/graphs/:name/nodes/:nodeId/traverse |
BFS to depth N (max 10; same filters) |
GET |
/api/graphs/:name/path |
Shortest path (cost=hops|weight, direction) |
GET |
/api/graphs/:name/subgraph |
Neighborhood around seed nodes (same filters) |
GET |
/api/graphs/:name/stats |
Counts, label histograms, weight sums, degrees |
Graph mutations publish realtime events on the db:<graph> topic - see
Realtime.
- Getting Started
- JS/TS SDK
- Authentication
- Databases
- Collections
- Graphs
- Rules and Access Control
- Granular Access Control
- Triggers
- Plugins
- Realtime
- Full Text Search
- Vector Embeddings
- Workflows
- Pipelines
- Cron
- Webhooks
- Migrations
- Replication
- File Sync
- Backup
- Distributed Realtime
- Tutorial: Embeddings
- Tutorial: Graphs
- Recipe: Calendar Timetree
- Tutorial: Plugins