Table of contents
- Knowledge Graph Tutorial: Linking Records Across Collections
- What You'll Build
- Architecture
- Prerequisites
- Quick Start
- Step 1: Create the Collections
- Step 2: Create the Graph
- Step 3: Mirror the Records as Nodes
- Step 4: Add the Relationships
- Step 5: Ask Relationship Questions
- Who works on Atlas?
- What breaks if the database schema changes?
- How are Alice and Carol connected?
- Pull a neighborhood for visualization
- Step 6: Keep the Graph in Sync
- Step 7: Open It Up (Rules)
- Step 8: Explore in the Dashboard
- Full Script
- Troubleshooting
- node not found when creating edges
- graph edge cap exceeded (400)
- graph not found (404) although it exists
- Traversal returns empty results
- Next Steps
- Resources
Knowledge Graph Tutorial: Linking Records Across Collections
Collections are great for storing records, but "who works with whom", "what does this document depend on", "how are these two people connected" - those are relationship questions, and answering them with filters means rebuilding joins in your application. VSKI graphs exist for exactly that.
This tutorial builds a small company knowledge base: people, projects and documents live in collections, and a graph indexes how they relate. At the end you can answer relationship questions in one API call - and visualize the result in the dashboard.
Note
This page covers the graph API itself. For the reference documentation see Graphs.
What You'll Build
A kb graph over three collections, with three relationship types:
works_on- person -> project (weight: hours per week)authored- person -> documentdepends_on- document -> document (weight: coupling, lower = tighter)
and the queries that come with it:
- who works on a project (and what else they work on)
- the full dependency impact of changing one document
- the closest connection between two people, weighted by involvement
- a neighborhood subgraph for the dashboard
Architecture
┌─────────────────┐ ┌──────────────────────────────┐
│ Script/Cli │─────▶│ VSKI Server │
│ (Deno/TS) │ HTTP │ │
└─────────────────┘ │ ┌────────────────────────┐ │
│ │ collections │ │
│ │ users, projects, docs │ │
│ ├────────────────────────┤ │
│ │ graph kb │ │
│ │ nodes + labelled │ │
│ │ edges (SQLite) │ │
│ │ + in-memory index │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
Records live in collections. The graph references them by
properties.ref = { collection, recordId } and adds the relationship layer
on top. Neither side owns the other: you can re-index the graph from the
collections at any time.
Prerequisites
- Docker
- Deno
- The VSKI CLI:
deno install --global -A -f -n vski https://vski.sh/x/vski-js/raw/branch/main/dist/main.js
Quick Start
1. Start VSKI
# docker-compose.yml
services:
vski:
image: vski.sh/x/vski:latest-standalone
ports:
- "8347:8347"
environment:
- SERVER_PORT=8347
- DATA_DIR=/app/data
- JWT_SECRET=dev-secret-change-in-production
volumes:
- vski_data:/app/data
volumes:
vski_data:
docker compose up -d
Create your admin at http://localhost:8347/installer, then log in with the CLI:
vski login --url http://localhost:8347 --email admin@example.com --password yourpassword
vski db create kb
Step 1: Create the Collections
The data itself is ordinary collections. Only the fields the graph rules will look at later need to be there from the start.
// src/setup.ts
import { VskiClient } from "@vski/sdk";
const client = new VskiClient("http://localhost:8347");
await client.auth.login("admin@example.com", "yourpassword");
const db = client.db("kb");
await db.settings.collections.create({
name: "users",
type: "base",
fields: [
{ name: "email", type: "email", required: true },
{ name: "name", type: "text", required: true },
],
});
await db.settings.collections.create({
name: "projects",
type: "base",
fields: [{ name: "title", type: "text", required: true }],
});
await db.settings.collections.create({
name: "documents",
type: "base",
fields: [
{ name: "title", type: "text", required: true },
{ name: "status", type: "select", options: { values: ["draft", "review", "final"] } },
],
});
And some records:
const alice = await db.collection("users").create({
email: "alice@example.com", name: "Alice",
});
const bob = await db.collection("users").create({
email: "bob@example.com", name: "Bob",
});
const carol = await db.collection("users").create({
email: "carol@example.com", name: "Carol",
});
const atlas = await db.collection("projects").create({ title: "Atlas" });
const api = await db.collection("documents").create({
title: "API design", status: "review",
});
const dbSchema = await db.collection("documents").create({
title: "Database schema", status: "final",
});
const onboarding = await db.collection("documents").create({
title: "Onboarding guide", status: "draft",
});
Step 2: Create the Graph
await db.settings.graphs.create({
name: "kb",
options: {
directed: true,
maxNodes: 10000, // this graph stays small; keep the caps tight
maxEdges: 50000,
},
});
const kb = db.graph("kb");
Exceeding a cap returns a clean 400 (graph edge cap exceeded) instead of
growing forever - set caps to what the graph is allowed to be, not what
it is today.
Step 3: Mirror the Records as Nodes
One node per record, with ref pointing back at the source of truth:
const nodes = [
{ id: "u:alice", label: "person", properties: { ref: { collection: "users", recordId: alice.id } } },
{ id: "u:bob", label: "person", properties: { ref: { collection: "users", recordId: bob.id } } },
{ id: "u:carol", label: "person", properties: { ref: { collection: "users", recordId: carol.id } } },
{ id: "p:atlas", label: "project", properties: { ref: { collection: "projects", recordId: atlas.id } } },
{ id: "d:api", label: "document", properties: { ref: { collection: "documents", recordId: api.id } } },
{ id: "d:schema", label: "document", properties: { ref: { collection: "documents", recordId: dbSchema.id } } },
{ id: "d:onboarding", label: "document", properties: { ref: { collection: "documents", recordId: onboarding.id } } },
];
await kb.createNodes(nodes); // one bulk request
The u: / p: / d: prefixes make ids globally unique inside the graph
(collection record ids alone could collide) and make substring search
useful - q: "d:" finds every document node.
Step 4: Add the Relationships
await kb.createEdges([
// person -> project, weight = hours per week
{ source: "u:alice", target: "p:atlas", label: "works_on", weight: 20 },
{ source: "u:bob", target: "p:atlas", label: "works_on", weight: 30 },
{ source: "u:carol", target: "p:atlas", label: "works_on", weight: 10 },
// person -> document
{ source: "u:alice", target: "d:api", label: "authored" },
{ source: "u:bob", target: "d:schema", label: "authored" },
{ source: "u:carol", target: "d:onboarding", label: "authored" },
// document -> document, weight = coupling (lower = tighter)
{ source: "d:api", target: "d:schema", label: "depends_on", weight: 1 },
{ source: "d:onboarding", target: "d:api", label: "depends_on", weight: 2 },
]);
Edges require both endpoints to exist - a missing node fails the whole
request with node not found.
Step 5: Ask Relationship Questions
Who works on Atlas?
One hop, into the project node:
const team = await kb.neighbors("p:atlas", { direction: "in" });
console.log(team.nodes.map((n) => n.id));
// ["u:alice", "u:bob", "u:carol"]
What breaks if the database schema changes?
Walk depends_on edges pointing at the schema document - everything that
(transitively) depends on it is affected, whatever the distance:
const impact = await kb.traverse("d:schema", {
depth: 10, // hard server maximum
direction: "in", // things that depend on it
labels: ["depends_on"],
});
console.log(impact.nodes.map((n) => n.id));
Note the direction: depends_on points from dependent to dependency, so
"what is affected by X" is an inbound walk.
How are Alice and Carol connected?
The path finder answers this in one call. With cost: "hops" you get the
fewest-step route; with cost: "weight" Dijkstra minimizes the summed
weight:
const byHops = await kb.path("u:alice", "u:carol", { cost: "hops" });
console.log(byHops.nodes!.map((n) => n.id));
// ["u:alice", "p:atlas", "u:carol"] - 2 hops through the shared project
console.log(byHops.cost); // 30 (Alice's 20 + Carol's 10 weekly hours)
const byWeight = await kb.path("u:alice", "u:carol", { cost: "weight" });
console.log(byWeight.nodes!.map((n) => n.id));
// ["u:alice", "d:api", "d:onboarding", "u:carol"] - cheaper through documents
console.log(byWeight.cost); // 4 (authored = 1, depends_on = 2, authored = 1)
Two path semantics worth knowing: the finder follows edges in both
directions (unlike neighbors/traverse, it has no direction
parameter), and unweighted edges count as weight 1 - which is why the
document route wins once weights are summed.
Restricting the walk to certain relationship types keeps the answer meaningful:
const viaWork = await kb.path("u:alice", "u:carol", {
cost: "weight",
labels: ["works_on"], // collaboration edges only
});
// ["u:alice", "p:atlas", "u:carol"] - the documents are excluded
Pull a neighborhood for visualization
The subgraph endpoint exists for rendering - seed nodes plus everything within a few hops, capped at 5 hops / 1000 nodes:
const hood = await kb.subgraph(["u:alice"], { hops: 2 });
console.log(`${hood.nodes.length} nodes, ${hood.edges.length} edges`);
// "4 nodes, 3 edges": alice, p:atlas, d:api and d:schema
Like neighbors and traverse, the walk follows outbound edges by
default - pass direction: "both" to pull inbound relationships too
(useful when the seed is a "sink", e.g. a project everyone points at).
And check the shape of the whole graph without touching a node:
const stats = await kb.stats();
console.log(stats.edgeLabels); // { works_on: 3, authored: 3, depends_on: 2 }
console.log(stats.avgDegree.toFixed(2), stats.maxDegree);
Step 6: Keep the Graph in Sync
Records change; the graph should follow. Because nodes carry ref, sync is
a small loop:
// src/sync.ts - run whenever records change (e.g. from a trigger)
async function syncUser(record: { id: string }) {
const nodeId = `u:${record.id}`;
const existing = await kb.getNodes({ q: nodeId });
if (existing.totalItems === 0) {
await kb.createNodes({
id: nodeId,
label: "person",
properties: { ref: { collection: "users", recordId: record.id } },
});
}
}
For deletions, remember that deleting a node cascades its edges - removing
u:bob when Bob leaves cleanly disconnects him from every project and
document. The reverse also holds: deleting a collection record does not
touch the graph, so sync hooks (or the re-index script below) should handle
that side.
Re-indexing from scratch is the same three steps as the initial build: create a fresh graph, mirror records, replay relationships.
Step 7: Open It Up (Rules)
Graphs use the collection rule model. This config keeps structure edits admin-only but lets any authenticated user explore:
const graphs = await db.settings.graphs.getList();
const cfg = graphs.items.find((g) => g.name === "kb")!;
await db.settings.graphs.update(cfg.id as string, {
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''", // gates neighbors/traverse/path/subgraph
createRule: "",
updateRule: "",
deleteRule: "",
});
Per-item rules see node fields, so a read-only layer is one line away:
listRule: "label = 'document'"
// every non-admin now sees just the three document nodes; persons and
// projects stay admin-only, and the response carries filtered: true
Step 8: Explore in the Dashboard
Open http://localhost:8347 - the sidebar has a
Graphs section. The kb explorer gives you:
- a searchable center-node picker (substring over ids and labels, backed
by the same
qparameter used above) - hops (1-5), limit and direction controls
- click-to-select with Center here / Expand actions
- a from/to shortest-path finder with hops or weight cost
- per-label edge colors and counts, node/edge create-edit-delete
Everything the tutorial did through the SDK is available interactively - handy for verifying the graph after each sync run.
Full Script
// src/main.ts - the whole tutorial as one runnable script
import { VskiClient } from "@vski/sdk";
const client = new VskiClient("http://localhost:8347");
await client.auth.login("admin@example.com", "yourpassword");
const db = client.db("kb");
// 1. collections
await db.settings.collections.create({
name: "users", type: "base",
fields: [
{ name: "email", type: "email", required: true },
{ name: "name", type: "text", required: true },
],
});
await db.settings.collections.create({
name: "projects", type: "base",
fields: [{ name: "title", type: "text", required: true }],
});
await db.settings.collections.create({
name: "documents", type: "base",
fields: [
{ name: "title", type: "text", required: true },
{ name: "status", type: "select", options: { values: ["draft", "review", "final"] } },
],
});
// 2. records
const [alice, bob, carol] = await Promise.all(
[
{ email: "alice@example.com", name: "Alice" },
{ email: "bob@example.com", name: "Bob" },
{ email: "carol@example.com", name: "Carol" },
].map((u) => db.collection("users").create(u)),
);
const atlas = await db.collection("projects").create({ title: "Atlas" });
const [api, schema, onboarding] = await Promise.all(
[
{ title: "API design", status: "review" },
{ title: "Database schema", status: "final" },
{ title: "Onboarding guide", status: "draft" },
].map((d) => db.collection("documents").create(d)),
);
// 3. graph
await db.settings.graphs.create({
name: "kb",
options: { directed: true, maxNodes: 10000, maxEdges: 50000 },
});
const kb = db.graph("kb");
// 4. nodes (ref -> collection records)
const ref = (collection: string, recordId: string) => ({ collection, recordId });
await kb.createNodes([
{ id: "u:alice", label: "person", properties: { ref: ref("users", alice.id) } },
{ id: "u:bob", label: "person", properties: { ref: ref("users", bob.id) } },
{ id: "u:carol", label: "person", properties: { ref: ref("users", carol.id) } },
{ id: "p:atlas", label: "project", properties: { ref: ref("projects", atlas.id) } },
{ id: "d:api", label: "document", properties: { ref: ref("documents", api.id) } },
{ id: "d:schema", label: "document", properties: { ref: ref("documents", schema.id) } },
{ id: "d:onboarding", label: "document", properties: { ref: ref("documents", onboarding.id) } },
]);
// 5. relationships
await kb.createEdges([
{ source: "u:alice", target: "p:atlas", label: "works_on", weight: 20 },
{ source: "u:bob", target: "p:atlas", label: "works_on", weight: 30 },
{ source: "u:carol", target: "p:atlas", label: "works_on", weight: 10 },
{ source: "u:alice", target: "d:api", label: "authored" },
{ source: "u:bob", target: "d:schema", label: "authored" },
{ source: "u:carol", target: "d:onboarding", label: "authored" },
{ source: "d:api", target: "d:schema", label: "depends_on", weight: 1 },
{ source: "d:onboarding", target: "d:api", label: "depends_on", weight: 2 },
]);
// 6. queries
const team = await kb.neighbors("p:atlas", { direction: "in" });
console.log("Atlas team:", team.nodes.map((n) => n.id));
const impact = await kb.traverse("d:schema", {
depth: 10, direction: "in", labels: ["depends_on"],
});
console.log("Schema change affects:", impact.nodes.map((n) => n.id));
const route = await kb.path("u:alice", "u:carol", { cost: "weight" });
console.log("Alice -> Carol:", route.nodes?.map((n) => n.id), "cost", route.cost);
// -> ["u:alice", "d:api", "d:onboarding", "u:carol"] cost 4
const stats = await kb.stats();
console.log("Labels:", stats.edgeLabels, "avgDegree", stats.avgDegree.toFixed(2));
client.close();
Run it with:
deno run -A src/main.ts
(@vski/sdk resolves from an import map - add
"@vski/sdk": "npm:@vski/sdk@^2.1.1" to your deno.json imports.)
Troubleshooting
node not found when creating edges
Both source and target must exist in the graph before the edge is
created. Check with graph.getNodes({ q: "u:" }) - remember that graph node
ids (u:alice) are not collection record ids.
graph edge cap exceeded (400)
The graph hit options.maxEdges (or the server-wide GRAPH_MAX_EDGES).
Raise the per-graph cap via settings.graphs.update and retry - the write
that failed did not partially apply.
graph not found (404) although it exists
Graphs are db-scoped with strict resolution: client.graph("kb") targets
the client's default database. Use client.db("kb").graph("kb") (as in
this tutorial) or client.graph("kb", "kb").
Traversal returns empty results
Two usual causes: direction (depends_on impact analysis is an inbound
walk) and labels filtering (labels must match exactly, including case and
empty strings).
Next Steps
- Wire the sync loop from Step 6 into Triggers so record changes update the graph automatically
- Subscribe to
db:kbrealtime events to live-update a visualization (Realtime) - Combine graph traversal with collection queries: resolve a route's node
ids to full records via
properties.ref - Explore weighting schemes -
works_onhours, review status, whatever makescost: "weight"meaningful in your domain
Resources
- 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