Graph.
Graph in OriginChain is not a second database next to your tables. It is a view over the rows you already have: you mark one column as pointing at another table, and from then on the engine maintains a traversable edge index behind it. Same rows, same instance, same writes.
Use it when the interesting part of a question is the connection rather than the value - fraud rings, referral trees, recommendation neighbourhoods, dependency chains, "who else touched this". If your question is really an aggregate with a join in it, SQL will be both faster and clearer.
The one modelling decision that matters.
This is where almost everyone goes wrong on their first schema, so it is worth being blunt about:
An edge is a column on the node table that holds the other row's primary key. It is not a separate edge table with source and destination columns.
If you have used a property-graph database, the instinct is to build a join table. Here that produces a table nothing can traverse:
# The instinct from other graph databases - DON'T do this.
namespace = "shop"
table = "order_customer_edges"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
[[columns]]
name = "src" # order id
ty = "str"
[[columns]]
name = "dst" # customer id
ty = "str"
# This is just a table. Nothing traverses it. Every graph endpoint
# and every Cypher arrow will refuse to touch it, because no
# [[relations]] block points anywhere.
The working version puts the pointer on shop.orders itself. Register the target table first - a relation whose target does not exist yet fails validation.
namespace = "shop"
table = "customers"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "name"
ty = "str"
[[columns]]
name = "country"
ty = "str"
[[columns]]
name = "referred_by"
ty = "str" # another customer's id
# A self-relation: customers point at customers.
[[relations]]
name = "referrer"
from_col = "referred_by"
target = { namespace = "shop", table = "customers", pk = "id" }
bidirectional = true
[[indexes]]
name = "by_id"
columns = ["id"] namespace = "shop"
table = "orders"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "customer" # <- THIS column is the edge
ty = "str"
[[columns]]
name = "amount_cents"
ty = "i64"
[[columns]]
name = "status"
ty = "str"
[[columns]]
name = "notes"
ty = "str"
[[columns]]
name = "placed_ms"
ty = "u64"
# The declaration that turns a plain column into a traversable edge.
[[relations]]
name = "placed_by" # the name you pass as ?rel=
from_col = "customer" # the column ON THIS TABLE
target = { namespace = "shop", table = "customers", pk = "id" }
bidirectional = true # default; enables reverse traversal
[[indexes]]
name = "by_id"
columns = ["id"] | Field | What it means |
|---|---|
| name | The edge's verb. This is the string you pass as ?rel= on every graph endpoint and inside every Cypher arrow. It is not the column name. |
| from_col | The column on this table whose value identifies the far row. The single most misread field on the page - it is a local column, not the target's. |
| target | { namespace, table, pk }. pk must be the target's single primary-key column. Cross-namespace targets are fine. |
| bidirectional | Defaults to true. Writes a reverse edge as well, which is what makes /reverse and backward Cypher arrows work. Set it to false and those return empty rather than erroring. |
There is no edge API
Once the relation is declared, you never write an edge. You write an ordinary row, and the engine derives the forward and reverse edge keys inside the same commit. Delete the row and they go with it; change the column and the edge moves.
POST /v1/tenants/:t/rows/shop.orders # Write an ORDINARY row. There is no edge API and nothing else to call.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/rows/shop.orders" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
"customer": "01JTRX1H4Q9P0N2WMX0F5JZ001",
"amount_cents": 12950,
"status": "paid",
"notes": "rush delivery",
"placed_ms": 1714478049000
}'
# The edge order -> customer now exists. Both directions, because
# placed_by declares bidirectional = true.
// The TS SDK doesn't wrap row writes yet - plain fetch.
await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/rows/shop.orders`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: "01JTRX9KQ3YH8K2WMX0F5JZAB7",
customer: "01JTRX1H4Q9P0N2WMX0F5JZ001",
amount_cents: 12950,
status: "paid",
notes: "rush delivery",
placed_ms: 1714478049000,
}),
},
);
// The edge exists now. No separate edge write.
db.rows.put("shop.orders", {
"id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
"customer": "01JTRX1H4Q9P0N2WMX0F5JZ001",
"amount_cents": 12950,
"status": "paid",
"notes": "rush delivery",
"placed_ms": 1714478049000,
})
# The edge exists now. No separate edge write.
// The Go SDK doesn't wrap row writes yet - net/http.
body, _ := json.Marshal(map[string]any{
"id": "01JTRX9KQ3YH8K2WMX0F5JZAB7",
"customer": "01JTRX1H4Q9P0N2WMX0F5JZ001",
"amount_cents": 12950,
"status": "paid",
"notes": "rush delivery",
"placed_ms": uint64(1714478049000),
})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+
"/rows/shop.orders",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
// The edge exists now. No separate edge write.
many-to-many from one column
If from_col holds a JSON array instead of a single value, the engine emits one edge per element. That is how you model a genuine many-to-many - an order with several tags, a document with several authors - without ever creating a join table. A missing or null value emits no edge at all.
One more thing worth knowing: the FK column does not need a secondary index. Edges live in their own key space, derived at write time, and traversal is a prefix scan over that space rather than a lookup on the column. The by_id index in the TOML above is there for Cypher, which anchors patterns by primary key - the graph endpoints do not need it.
One hop.
/neighbors is the primitive everything else is built on. All four languages wrap it.
GET /v1/tenants/:t/graph/:schema/neighbors # Who placed this order?
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.orders/neighbors?rel=placed_by&pk=01JTRX9KQ3YH8K2WMX0F5JZAB7" \
-H "Authorization: Bearer $OC_TOKEN"
const pks = await db.graph.neighbors("shop.orders", {
rel: "placed_by",
pk: "01JTRX9KQ3YH8K2WMX0F5JZAB7",
});
console.log(pks); // string[] - raw primary keys
hits = db.graph.neighbors(
"shop.orders",
rel="placed_by",
pk="01JTRX9KQ3YH8K2WMX0F5JZAB7",
)
for n in hits:
print(n.pk, n.depth) # depth is always 1 here
hits, err := db.Graph().Neighbors(ctx, "shop.orders", originchain.NeighborsRequest{
Rel: "placed_by",
PK: "01JTRX9KQ3YH8K2WMX0F5JZAB7",
})
if err != nil { /* handle */ }
for _, n := range hits {
fmt.Println(n.PK, n.Depth) // Depth is always 1 here
}
response ["01JTRX1H4Q9P0N2WMX0F5JZ001"]
Note what comes back: primary keys, not rows. The graph endpoints return identity, not content. If you want the customer's name you either fetch the row afterwards, or use Cypher / a plan query, which return full rows.
Going the other way
The relation is declared on shop.orders, and it stays declared there no matter which direction you walk. /reverse is still addressed as graph/shop.orders/…, but the pk you pass is a customer.
GET /v1/tenants/:t/graph/:schema/reverse # Flip it: which orders did this customer place?
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.orders/reverse?rel=placed_by&pk=01JTRX1H4Q9P0N2WMX0F5JZ001" \
-H "Authorization: Bearer $OC_TOKEN"
const pks = await db.graph.reverseNeighbors("shop.orders", {
rel: "placed_by",
pk: "01JTRX1H4Q9P0N2WMX0F5JZ001",
});
console.log(pks.length, "orders for this customer");
hits = db.graph.reverse_neighbors(
"shop.orders",
rel="placed_by",
pk="01JTRX1H4Q9P0N2WMX0F5JZ001",
)
print(len(hits), "orders for this customer")
hits, err := db.Graph().ReverseNeighbors(ctx, "shop.orders", originchain.NeighborsRequest{
Rel: "placed_by",
PK: "01JTRX1H4Q9P0N2WMX0F5JZ001",
})
if err != nil { /* handle */ }
fmt.Println(len(hits), "orders for this customer")
empty is not the same as wrong /reverse returns [] - not an error - in three different situations: the node genuinely has no in-edges, the relation was declared bidirectional = false, or you typo'd the rel name. Reverse lookups do not validate that the relation exists. Check your spelling before concluding the graph is empty.
Many hops.
/bfs expands outward from a node and tags every result with its distance. This is the workhorse for "everything within N hops".
GET /v1/tenants/:t/graph/:schema/bfs # Everyone within 3 referral hops of this customer.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/bfs?rel=referrer&pk=01JTRX1H4Q9P0N2WMX0F5JZ001&max_depth=3" \
-H "Authorization: Bearer $OC_TOKEN"
const hits = await db.graph.bfs("shop.customers", {
rel: "referrer",
pk: "01JTRX1H4Q9P0N2WMX0F5JZ001",
max_depth: 3,
});
for (const h of hits) console.log(h.depth, h.pk);
hits = db.graph.bfs(
"shop.customers",
rel="referrer",
pk="01JTRX1H4Q9P0N2WMX0F5JZ001",
max_depth=3,
)
for h in hits:
print(h.depth, h.pk)
hits, err := db.Graph().BFS(ctx, "shop.customers", originchain.BFSRequest{
Rel: "referrer",
PK: "01JTRX1H4Q9P0N2WMX0F5JZ001",
MaxDepth: 3,
})
if err != nil { /* handle */ }
for _, h := range hits {
fmt.Println(h.Depth, h.PK)
}
response [
{ "pk": "01JTRX1H4Q9P0N2WMX0F5JZ004", "depth": 1 },
{ "pk": "01JTRX1H4Q9P0N2WMX0F5JZ011", "depth": 2 },
{ "pk": "01JTRX1H4Q9P0N2WMX0F5JZ027", "depth": 3 }
]
Reachability and routes
/path answers one question - can I get there - and answers it cheaply.
GET /v1/tenants/:t/graph/:schema/path curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/path?rel=referrer&src=01JTRX1H4Q9P0N2WMX0F5JZ001&dst=01JTRX1H4Q9P0N2WMX0F5JZ027&max_depth=3" \
-H "Authorization: Bearer $OC_TOKEN"
const res = await db.graph.path("shop.customers", {
rel: "referrer",
src: "01JTRX1H4Q9P0N2WMX0F5JZ001",
dst: "01JTRX1H4Q9P0N2WMX0F5JZ027",
max_depth: 3,
});
console.log(res.reachable); // boolean - no node list
res = db.graph.path(
"shop.customers",
rel="referrer",
src="01JTRX1H4Q9P0N2WMX0F5JZ001",
dst="01JTRX1H4Q9P0N2WMX0F5JZ027",
max_depth=3,
)
print(res.reachable) # True / False - no node list
res, err := db.Graph().Path(ctx, "shop.customers", originchain.PathRequest{
Rel: "referrer",
Src: "01JTRX1H4Q9P0N2WMX0F5JZ001",
Dst: "01JTRX1H4Q9P0N2WMX0F5JZ027",
MaxDepth: 3,
})
if err != nil { /* handle */ }
fmt.Println(res.Reachable) // bool - no node list
/path does not return a path
The response is { "reachable": true } and nothing else. The route is not materialised. If you need the actual nodes, use /k-shortest with k=1, which does return a node list and a cost.
# Want the actual route, with costs? Use k-shortest.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/k-shortest?rel=referrer&source=01JTRX1H4Q9P0N2WMX0F5JZ001&target=01JTRX1H4Q9P0N2WMX0F5JZ027&k=3" \
-H "Authorization: Bearer $OC_TOKEN"
response {
"paths": [
{ "nodes": ["...001", "...004", "...027"], "cost": 2.0 },
{ "nodes": ["...001", "...011", "...019", "...027"], "cost": 3.0 }
]
}
Weighting differs between the two weighted endpoints, and it trips people up. /dijkstra takes a weights_json map keyed by "from_pk|to_pk" that you supply in the request - weights are not stored on edges. /k-shortest is usually what you want instead: pass weight_col and it reads the weight from a column on the destination row, defaulting to 1.0 per hop.
Traversal that returns rows: RelationHop.
Underneath the REST endpoints, a hop is a query-plan operator called RelationHop. It reads the forward edge index by prefix and then point-gets each destination row - so unlike /neighbors, it hands back complete rows.
You can post a plan directly to /v1/tenants/:t/query. The plan is plain JSON, tagged by op:
{
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"target": "shop.customers"
}
POST /v1/tenants/:t/query curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/query" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"target": "shop.customers"
}'
const res = await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/query`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
op: "relation_hop",
schema: "shop.orders",
rel: "placed_by",
from_pk: ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
target: "shop.customers",
}),
},
);
const rows = await res.json(); // full target rows, not just PKs
import os, requests
BASE = f"https://{os.environ['OC_HOST']}/v1/tenants/{os.environ['OC_TENANT']}"
H = {"Authorization": f"Bearer {os.environ['OC_TOKEN']}"}
rows = requests.post(f"{BASE}/query", headers=H, json={
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"target": "shop.customers",
}).json()
print(rows) # full target rows, not just PKs
plan := map[string]any{
"op": "relation_hop",
"schema": "shop.orders",
"rel": "placed_by",
"from_pk": []string{"01JTRX9KQ3YH8K2WMX0F5JZAB7"},
"target": "shop.customers",
}
body, _ := json.Marshal(plan)
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+"/query",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()
var rows []map[string]any // full target rows, not just PKs
json.NewDecoder(resp.Body).Decode(&rows)
Filtering mid-traversal
Chaining hops uses a sibling operator, RelationChain, and this is where plans earn their keep: each hop can carry its own predicate, applied to that hop's output before the next hop expands from it. That is push-down filtering, not post-filtering - a selective predicate early in the chain cuts the work everything after it does.
{
"op": "relation_chain",
"from_schema": "shop.orders",
"from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
"hops": [
{ "rel": "placed_by", "target": "shop.customers" },
{
"rel": "referrer",
"target": "shop.customers",
"where_predicate": { "op": "eq", "path": "country", "value": "NG" }
}
]
}
Only the final hop's rows come back. And because these are ordinary plan nodes, the usual operators compose around them - filter, project, sort, limit, distinct, aggregate.
cycles fan out exponentially RelationChain does not de-duplicate rows between hops and carries no built-in depth cap. On a graph with cycles, A → B → A → … expands combinatorially with every hop you add. Cap the depth yourself, keep chains short, and prefer /bfs - which does track visited nodes - when the shape is "everything within N hops".
Most people should reach for Cypher rather than hand-writing plans - MATCH (o:orders {id: "..."})-[:placed_by]->(c) RETURN c.name compiles to exactly the plan above. Raw plans are there for generated queries and for shapes Cypher does not express.
Graph algorithms.
These are real, callable endpoints - not patterns you assemble yourself. Twenty of them, each a single HTTP call against a declared relation. Two worked examples first, then the full catalogue.
PageRank
Ranks influence within a set of nodes you nominate. The nodes parameter is required - PageRank scores a subgraph you define, it does not rank an entire table on its own, and an empty list is a 400.
GET /v1/tenants/:t/graph/:schema/pagerank # PageRank needs an explicit node universe - it does not rank a whole
# table for you. Pass the PKs you want scored.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/pagerank?rel=referrer&nodes=c001,c002,c003,c004,c005&damping=0.85" \
-H "Authorization: Bearer $OC_TOKEN"
// The TS SDK wraps neighbors / reverse / bfs / path / dijkstra only.
// The algorithm endpoints are plain GETs.
const qs = new URLSearchParams({
rel: "referrer",
nodes: "c001,c002,c003,c004,c005",
damping: "0.85",
});
const res = await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/graph/shop.customers/pagerank?${qs}`,
{ headers: { "Authorization": `Bearer ${process.env.OC_TOKEN}` } },
);
const hits: { pk: string; score: number }[] = await res.json();
for (const h of hits) console.log(h.pk, h.score);
scores = db.graph.pagerank(
"shop.customers",
rel="referrer",
nodes=["c001", "c002", "c003", "c004", "c005"],
damping=0.85,
)
for pk, score in sorted(scores.items(), key=lambda kv: -kv[1]):
print(pk, round(score, 4))
// The Go SDK wraps Neighbors / ReverseNeighbors / BFS / Path / Dijkstra
// only. The algorithm endpoints are plain GETs.
q := url.Values{}
q.Set("rel", "referrer")
q.Set("nodes", "c001,c002,c003,c004,c005")
q.Set("damping", "0.85")
req, _ := http.NewRequestWithContext(ctx, "GET",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+
"/graph/shop.customers/pagerank?"+q.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()
var hits []struct {
PK string `json:"pk"`
Score float64 `json:"score"`
}
json.NewDecoder(resp.Body).Decode(&hits)
for _, h := range hits { fmt.Println(h.PK, h.Score) }
response [
{ "pk": "c001", "score": 0.3120 },
{ "pk": "c004", "score": 0.2455 },
{ "pk": "c002", "score": 0.1810 }
]
Louvain communities
Partitions the graph into clusters by modularity. Unlike PageRank it takes the whole relation, and community ids are dense integers starting at zero.
GET /v1/tenants/:t/graph/:schema/louvain curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/louvain?rel=referrer" \
-H "Authorization: Bearer $OC_TOKEN"
const res = await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/graph/shop.customers/louvain?rel=referrer`,
{ headers: { "Authorization": `Bearer ${process.env.OC_TOKEN}` } },
);
const { communities } = await res.json();
for (const c of communities) console.log(c.community, c.pk);
communities = db.graph.louvain("shop.customers", rel="referrer")
# {pk -> community_id}, ids are dense integers from 0
for pk, cid in communities.items():
print(cid, pk)
req, _ := http.NewRequestWithContext(ctx, "GET",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+
"/graph/shop.customers/louvain?rel=referrer", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()
var out struct {
Communities []struct {
PK string `json:"pk"`
Community int `json:"community"`
} `json:"communities"`
}
json.NewDecoder(resp.Body).Decode(&out)
for _, c := range out.Communities { fmt.Println(c.Community, c.PK) }
response {
"communities": [
{ "pk": "c001", "community": 0 },
{ "pk": "c002", "community": 0 },
{ "pk": "c007", "community": 1 }
]
}
The full catalogue
All twenty live under /v1/tenants/:t/graph/:schema/ and take rel=. Everything is GET except the two embedding trainers.
Endpoint Shape What it's for neighbors GET ?rel=&pk= One hop forward. Returns a bare array of primary keys. reverse GET ?rel=&pk= One hop backward. Needs bidirectional = true. bfs GET ?rel=&pk=&max_depth= Breadth-first frontier with depths. Default depth 3. path GET ?rel=&src=&dst=&max_depth= Reachability only - returns { reachable } and no route. dijkstra GET ?rel=&src=&dst=&weights_json= Cheapest weighted route. Weights come from the request, not storage. k-shortest GET ?rel=&source=&target=&k=&weight_col= Yen's k loop-free routes, with node lists. Max k = 50. all_simple_paths GET ?rel=&src=&dst=&max_depth=&max_paths= Every acyclic route between two nodes. Default cap 256 paths. all_simple_paths_bidir GET ?rel=&src=&dst=&max_depth=&max_paths= Same, searching from both ends. Needs bidirectional = true. pagerank GET ?rel=&nodes=&damping=&max_iter=&tol= Influence ranking by power iteration. nodes= is required. triangles GET ?rel= Triangle enumeration, each reported once in canonical order. components GET ?rel= Connected components by union-find. Undirected interpretation. betweenness GET ?rel=&max_nodes= Brandes' betweenness - finds bridges. Clamped at 100k nodes. eigenvector_centrality GET ?rel=&max_iter=&tol= Influence weighted by neighbours' influence. label_propagation GET ?rel=&max_iter=&seed= Fast community detection. Pass seed= or results are not reproducible. louvain GET ?rel=&tolerance=&max_levels= Modularity-based communities. Up to 500k nodes. random-walk GET ?rel=&start=&steps=&seed=&p=&q= Biased random walk sampling. Max 1,000 steps. node2vec POST { rel, dim, walks_per_node, …, persist } Train structural embeddings. persist: true enables topk. node2vec/:rel/topk GET ?pk=&k=&metric= Nearest nodes by persisted Node2Vec embedding. graphsage POST { rel, dim, layers, feature_col, persist } Attribute-aware embeddings that read a feature column. graphsage/:rel/topk GET ?pk=&k=&metric= Nearest nodes by persisted GraphSAGE embedding.
not implemented
Named here so you don't go looking:
- Strongly connected components (Tarjan / Kosaraju) - components is undirected only
- Topological sort
- Minimum spanning tree
- Clustering coefficient - triangles gives you the raw counts to compute it yourself
SDK coverage is uneven. TypeScript and Go wrap the five traversal endpoints - neighbors, reverse, bfs, path, dijkstra - and nothing else. Python additionally wraps k-shortest, shortest-path, random-walk, PageRank, Louvain, label propagation, betweenness, and the Node2Vec / GraphSAGE top-k calls. Everything else is a plain GET, as the tabs above show.
Limits and gotchas.
- Primary keys must be single-column strings. The graph endpoints encode
pk, src and dst as strings, so a table with an integer or composite primary key cannot be addressed through them at all. Model graph node ids as str from the start - ULIDs are the usual choice.
- The REST endpoints are single-schema. Each call resolves against one schema's catalog, so a traversal that has to cross into another namespace is not expressible here. Declaring a cross-namespace relation is fine - traversing one needs Cypher or a plan query.
- Graph is an add-on. Every
/graph/* route returns 402 with {"addon":"graph"} if the capability is not enabled on your instance. Traversal via Cypher goes through a different route and a different check.
- Some caps clamp, some reject.
k above 50 on k-shortest is a 400 - deliberately, so you budget rather than silently getting fewer paths. Betweenness above its node ceiling clamps instead. Know which one you are relying on.
- Ceilings worth writing down: variable-length depth 64, k-shortest k 50, random walk 1,000 steps, betweenness 100,000 nodes, Louvain 500,000 nodes, Node2Vec and GraphSAGE 100,000 nodes and 1,024 dimensions, and an 8 MiB HTTP body cap.
- Label propagation is non-deterministic unless you pass
seed. Without one the server seeds from the clock, and the seed is not echoed back - so you cannot reproduce a run after the fact. Always pass your own.
- Connected components is undirected. It unions both endpoints of every edge, so it will not give you strongly connected components on a directed graph.
- Graph calls are admission-controlled. They are classed as heavy operations and can return
429 with a Retry-After under memory pressure, or 413 when a result exceeds the size budget. Both are protective - retry or narrow the query rather than looping hard.
- Turning
bidirectional off is a one-way door in practice. The reverse edges are written at row-write time, so flipping the flag later does not backfill them for rows already stored. Rewrite the rows if you change your mind.
Related.
cypher Cypher Pattern syntax over the same relations, returning full rows. reference Graph endpoint reference Every parameter and tuning knob, endpoint by endpoint. schemas Schema reference Every TOML block, including relations, in one place. dashboard Design a schema visually Draw relations between tables on a canvas instead of writing TOML.