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
Section titled “Creating Graphs”Graphs are declared in the IaC YAML (see IaC) and applied with
vski apply:
version: 1database: default
graphs: knowledge: options: directed: trueGraph 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
Section titled “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) |
A node is an id, an optional label, and free-form properties.
Create Nodes
Section titled “Create Nodes”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.
Upsert (idempotent create)
Section titled “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.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.
Read, Update, Delete
Section titled “Read, Update, Delete”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 tooList and Search
Section titled “List and Search”# Page through all nodesall_nodes = await graph.get_nodes(page=1, per_page=50)
# Exact label filterpeople = 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.
Property filters
Section titled “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.get_nodes(prop=["ref.collection=users", "capacity>=5"])await graph.get_edges(prop=["status!=draft", "title~meeting"])
# "null" compares against missing/null propertiesawait 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).
Create Edges
Section titled “Create Edges”e1 = await graph.create_edges( {"id": "e1", "source": "alice", "target": "bob", "label": "knows", "weight": 1})
# Bulk, and ids are optional here as wellawait 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.
Read, Update, Delete
Section titled “Read, Update, Delete”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 requestweek = 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
Section titled “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 view rule.
Neighbors (one hop)
Section titled “Neighbors (one hop)”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”).
Traverse (BFS to a depth)
Section titled “Traverse (BFS to a depth)”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 hopsPath (shortest route between two nodes)
Section titled “Path (shortest route between two nodes)”# Fewest hopsby_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"]) # Trueprint(by_weight["hops"]) # 2print(by_weight["cost"]) # sum of edge weights on the routeprint(by_weight["nodes"]) # the route, in orderTwo 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)
Section titled “Subgraph (neighborhood around seed nodes)”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"]) # 4print(stats["edgeCount"]) # 3print(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.5print(stats["maxDegree"]) # 2print(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: nullThe 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: itsid,label(source,targetfor edges) andproperties. For examplelist: "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
Section titled “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
Section titled “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.
SDK versions
Section titled “SDK versions”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.
API Endpoints
Section titled “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.