Skip to content

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.

Graphs are declared in the IaC YAML (see IaC) and applied with vski apply:

vski/default/graphs.yaml
version: 1
database: default
graphs:
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).

OptionDefaultDescription
directedtruefalse makes every traversal follow edges both ways
maxNodesserver capPer-graph node cap (cannot exceed the server-wide cap)
maxEdgesserver capPer-graph edge cap (cannot exceed the server-wide cap)

A node is an id, an optional label, and free-form properties.

graph = client.db("mydb").graph("knowledge")
alice = await graph.create_nodes(
{"id": "alice", "label": "person",
"properties": {"ref": {"collection": "users", "recordId": "u1"}}}
)
# Bulk create is a single request and returns {"items": [...]}
batch = await graph.create_nodes([
{"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.

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.create_nodes(
{"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 update rule (the incoming item already passes the create rule like any create). The whole batch is written in one transaction: on failure nothing is persisted.

node = await graph.get_node("alice")
updated = await graph.update_node(
"alice", label="admin", properties={"role": "admin"} # replaces the object
)
await graph.delete_node("bob") # incident edges are deleted too
# Page through all nodes
all_nodes = await graph.get_nodes(page=1, per_page=50)
# Exact label filter
people = await graph.get_nodes(label="person")
# Substring search across id and label (case-insensitive, ASCII)
hits = await graph.get_nodes(q="ali")
# Fetch specific ids (max 500)
pair = await graph.get_nodes(ids=["alice", "day:2026-10-22"])
# Filter inside properties (see "Property filters" below)
docs = await graph.get_nodes(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.

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.get_nodes(prop=["ref.collection=users", "capacity>=5"])
await graph.get_edges(prop=["status!=draft", "title~meeting"])
# "null" compares against missing/null properties
await graph.get_nodes(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.

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).

e1 = await graph.create_edges(
{"id": "e1", "source": "alice", "target": "bob",
"label": "knows", "weight": 1}
)
# Bulk, and ids are optional here as well
await graph.create_edges([
{"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, so create endpoints first. Edges support upsert=True exactly like nodes, replacing source, target, label, weight and properties of an existing id.

outgoing = await graph.get_edges(source="alice") # or target=, label=
by_label = await graph.get_edges(label="knows")
found = await graph.get_edges(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
week = await graph.get_edges(
target=["day:2026-10-19", "day:2026-10-20", "day:2026-10-21"],
prop=["hours>=1"],
)
relabeled = await graph.update_edge("e1", label="colleague")
# weight overwrites; weight_delta adds atomically (server-side, under the
# graph write lock: the classic counter for "hours accumulated on X")
bumped = await graph.update_edge("e1", weight_delta=0.5)
await graph.delete_edge("e1")

weight and weight_delta are mutually exclusive in one request. Accumulating into JSON properties still means read-modify-write, so keep accumulators in weight (it is also the only numeric the in-memory index tracks).

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 view rule.

out = await graph.neighbors("alice", direction="out")
print(out["nodes"]) # [{"id": "bob"}, {"id": "carol"}]
print(out["edges"])
inbound = await graph.neighbors("alice", direction="in")
either = await graph.neighbors(
"alice",
direction="both",
labels=["knows"], # restrict to these edge labels
node_labels=["document"], # restrict to these neighbor node labels
)

node_labels 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”).

result = await graph.traverse(
"alice",
depth=3, # max 10
direction="out",
labels=["knows"],
node_labels=["person"], # nodes to follow/return
)
print(result["nodes"]) # every node reachable within 3 hops
# Fewest hops
by_hops = await graph.path("alice", "dave", cost="hops")
# Cheapest route by edge weight (Dijkstra)
by_weight = await graph.path("alice", "dave", cost="weight")
# Directed: refuse to walk edges backwards (out = source->target only)
forward = await graph.path("alice", "dave", direction="out")
print(by_weight["found"]) # True
print(by_weight["hops"]) # 2
print(by_weight["cost"]) # sum of edge weights on the route
print(by_weight["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)
hood = await graph.subgraph(["alice"], hops=2, limit=500)
print(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 / node_labels filter as everywhere else, and seeds are always included regardless of node_labels.

stats = await graph.stats()
print(stats["nodeCount"]) # 4
print(stats["edgeCount"]) # 3
print(stats["nodeLabels"]) # {"person": 3, "document": 1}
print(stats["edgeLabels"]) # {"knows": 3}
print(stats["edgeWeightSums"]) # {"knows": 7.5}: sum of weights per label
# (labels without any weighted edges omitted)
print(stats["avgDegree"]) # 1.5
print(stats["maxDegree"]) # 2
print(stats["maxNodesCap"]) # effective caps (min of graph + server)
print(stats["maxEdgesCap"])

From the command line: vski graphs stats knowledge.

Graphs use the collection rule fields with the same semantics and the same expression syntax (Rules and Access Control):

graphs:
knowledge:
rules:
list: null # unset = admins only (default)
view: "*" # "*" = any authenticated user
create: "@request.auth.id != ''"
update: null
delete: null

The HTTP API requires a valid token on every endpoint, so "*" means any authenticated user rather than anonymous internet access.

  • Gate rules (view, 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 (list, create, update, delete) are additionally evaluated against each node or edge: its id, label (source, target for edges) and properties. For example list: "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.

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.

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.

Everything on this page is available from vski-sdk 0.6.0 (v0.13 server additions: upsert, weight_delta, property filters, multi-id source/target, node_labels, directed path). The sync wrapper from vski.sync import SyncVskiClient mirrors the same surface with blocking calls.

MethodEndpointDescription
GET/api/graphsList graphs (all databases)
POST/api/graphsCreate graph
GET/api/graphs/:nameGet graph config
PATCH/api/graphs/:nameUpdate graph config
DELETE/api/graphs/:nameDelete graph (soft; data kept)
GET/api/graphs/:name/nodesList nodes (label, ids, q, prop, paging)
POST/api/graphs/:name/nodesCreate node(s) (?upsert=true)
GET/PATCH/DELETE/api/graphs/:name/nodes/:nodeIdNode read/update/delete
GET/api/graphs/:name/edgesList edges (source, target, label, q, prop; sources/targets accept comma lists)
POST/api/graphs/:name/edgesCreate edge(s) (?upsert=true)
GET/PATCH/DELETE/api/graphs/:name/edges/:edgeIdEdge read/update/delete (weightDelta on PATCH)
GET/api/graphs/:name/nodes/:nodeId/neighborsOne-hop neighbors (direction, labels, nodeLabels, limit)
GET/api/graphs/:name/nodes/:nodeId/traverseBFS to depth N (max 10; same filters)
GET/api/graphs/:name/pathShortest path (cost=hops|weight, direction)
GET/api/graphs/:name/subgraphNeighborhood around seed nodes (same filters)
GET/api/graphs/:name/statsCounts, label histograms, weight sums, degrees

Graph mutations publish realtime events on the db:<graph> topic; see Realtime.