Graph.
Graph in OriginChainDB 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.
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.
- 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.