2 Graphs
Anton Nesterov edited this page 2026-10-22 16:09:37 +02:00

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
// 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, and cost is always the summed weight along the returned route (in hops mode 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: its id, label (source, target for edges) and properties. For example listRule: "label = 'public'" lets non-admins list only nodes labelled public; when rules filtered items out, the list response carries "filtered": true and 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.