3. Social user (row + vector + graph self-relation)
← Atomic multi-shape
Save a user as a row in social.users, embed their bio for "find similar users", and model the follow graph as a follows list column on that same row. A [[relations]] self-relation turns each element of the list into a graph edge (alice → bob, alice → carol), and bidirectional = true maintains the reverse side so "who follows me?" is a graph read.
- "People you may know" - similar-user lookup over bio embeddings, intersected with friends-of-friends from the follow graph.
- Reverse-adjacency reads like "who follows me?" are a graph walk, not an index scan, because the bidirectional relation maintains both sides.
- Any social, professional, or collaboration network with self-relations between users.
Push this once. The follows list column plus the self-relation is the whole graph - no separate edge table to keep in sync.
# social/users.toml - one table models the profile AND the graph.
namespace = "social"
table = "users"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "handle"
ty = "str"
required = true
[[columns]]
name = "bio"
ty = "str"
[[columns]]
name = "joined_ms"
ty = "u64"
# The follow graph lives in a list column on the row. Each element is a
# user id this person follows.
[[columns]]
name = "follows"
ty = "list"
element = "str"
[[indexes]]
name = "by_handle"
columns = ["handle"]
# A self-relation turns the 'follows' list into graph edges: one forward
# edge per element (alice -> bob, alice -> carol). bidirectional = true also
# maintains the reverse side, so "who follows bob?" is a graph read, not a scan.
[[relations]]
name = "following"
from_col = "follows"
bidirectional = true
[relations.target]
namespace = "social"
table = "users"
pk = "id"
The follows array lands as graph edges in the same write. To follow or unfollow later, re-put the row with the updated list - the edges are recomputed from the new list on each write.
curl -X POST "$ORIGINCHAIN_URL/v1/tenants/$T/rows/social.users" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "alice",
"handle": "@alice",
"bio": "Mechanical engineer. Trail runner. Reading sci-fi.",
"joined_ms": 1746180851000,
"follows": ["bob", "carol"]
}'db.rows.put("social.users", {
"id": "alice",
"handle": "@alice",
"bio": "Mechanical engineer. Trail runner. Reading sci-fi.",
"joined_ms": 1746180851000,
"follows": ["bob", "carol"],
})// The TypeScript SDK writes rows via SQL INSERT (typed row helpers
// ship in a later release). Use `fetch` for the row-shaped write:
await fetch(`${BASE_URL}/v1/tenants/${TENANT}/rows/social.users`, {
method: "POST",
headers: {
"Authorization": `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: "alice",
handle: "@alice",
bio: "Mechanical engineer. Trail runner. Reading sci-fi.",
joined_ms: 1746180851000,
follows: ["bob", "carol"],
}),
});// The Go SDK writes rows via SQL INSERT (typed row helpers ship
// in a later release). Use net/http for the row-shaped write:
body, _ := json.Marshal(map[string]any{
"id": "alice",
"handle": "@alice",
"bio": "Mechanical engineer. Trail runner. Reading sci-fi.",
"joined_ms": uint64(1746180851000),
"follows": []string{"bob", "carol"},
})
req, _ := http.NewRequestWithContext(ctx, "POST",
BASE_URL+"/v1/tenants/"+TENANT+"/rows/social.users",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+OC_TOKEN)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
Use the same id as the user row. Now /vector/social.users/topk returns similar users by bio.
curl -X POST "$ORIGINCHAIN_URL/v1/tenants/$T/vector/social.users/put" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "alice",
"embedding": [0.0182, -0.0712, 0.0419, /* ... 768 floats ... */],
"dim": 768,
"metric": "cosine"
}'# Embed the bio (and optionally handle) so "find similar users" works.
db.vector.put(
"social.users",
"alice",
embedding_768d,
)await db.vectorPut("social.users", {
id: "alice",
embedding: embedding768d,
dim: 768,
metric: "cosine",
});err := db.VectorPut(ctx, "social.users", originchain.VectorPutRequest{
ID: "alice",
Embedding: embedding768d,
Dim: 768,
Metric: "cosine",
}) neighbors walks one hop forward along the relation (who alice follows); reverse walks the back-edge (who follows bob). Both are direct adjacency-list lookups - no scan, no join.
# Who does alice follow? (one hop forward along 'following')
curl "$ORIGINCHAIN_URL/v1/tenants/$T/graph/social.users/neighbors?rel=following&pk=alice" \
-H "Authorization: Bearer $OC_TOKEN"
# → ["bob", "carol"]
# Who follows bob? (reverse side, maintained by bidirectional = true)
curl "$ORIGINCHAIN_URL/v1/tenants/$T/graph/social.users/reverse?rel=following&pk=bob" \
-H "Authorization: Bearer $OC_TOKEN"
# → ["alice"]# Who does alice follow?
db.graph.neighbors("social.users", rel="following", pk="alice") # -> ["bob", "carol"]
# Who follows bob?
db.graph.reverse_neighbors("social.users", rel="following", pk="bob") # -> ["alice"]// Who does alice follow?
await oc.graph.neighbors("social.users", { rel: "following", pk: "alice" }); // ["bob","carol"]
// Who follows bob?
await oc.graph.reverseNeighbors("social.users", { rel: "following", pk: "bob" }); // ["alice"]// Who does alice follow?
db.Graph().Neighbors(ctx, "social.users",
originchain.NeighborsRequest{Rel: "following", PK: "alice"}) // ["bob","carol"]
// Who follows bob?
db.Graph().ReverseNeighbors(ctx, "social.users",
originchain.NeighborsRequest{Rel: "following", PK: "bob"}) // ["alice"]
The user row and the profile embedding are separate calls - each atomic by itself. There is no single "write everything" endpoint. The row write (including its follows edges) commits atomically, and the SDKs auto-attach an Idempotency-Key on every mutating call, so if the vector put fails after the row succeeded, retry just that one.
- Modeling follows as a composite-PK edge table. A
followstable keyed[follower_id, followee_id]can't be traversed withneighbors(pk="alice")- the source of each edge is the whole composite key, not a single user. Put the relation on theusersrow via afollowslist instead. - Forgetting the reverse direction. Set
bidirectional = trueso "who follows bob?" is areverseread. Without it you can only walk forward. - Partial re-put on follow/unfollow. Edges are recomputed from the
followslist on each write, so re-put the row with the full new list, not just the delta. - Updating handle without re-embedding. If you embed the handle and let users edit it, the vector goes stale until you re-put. Either re-put on every profile edit or only embed the bio.