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 follows as a separate edge table social.follows. The edge table's schema declares both follower_id and followee_id as relations pointing back to social.users, so each follow row write creates both directions in one commit.
- "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 these two schemas once. Note that the follow-edge table doesn't need an embedding or FTS - it's pure relational structure.
# social/users.toml
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"
[[indexes]]
name = "by_handle"
columns = ["handle"]
# social/follows.toml - the edge table.
# Declaring BOTH columns as relations makes one row write
# create both the forward and reverse edges automatically.
namespace = "social"
table = "follows"
primary_key = ["follower_id", "followee_id"]
[[columns]]
name = "follower_id"
ty = "str"
required = true
[[columns]]
name = "followee_id"
ty = "str"
required = true
[[columns]]
name = "created_ms"
ty = "u64"
[[relations]]
name = "follows"
from_col = "followee_id"
bidirectional = true
[relations.target]
namespace = "social"
table = "users"
pk = "id"
[[relations]]
name = "followed_by"
from_col = "follower_id"
bidirectional = true
[relations.target]
namespace = "social"
table = "users"
pk = "id" 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
}'db.rows.put("social.users", {
"id": "alice",
"handle": "@alice",
"bio": "Mechanical engineer. Trail runner. Reading sci-fi.",
"joined_ms": 1746180851000,
})// The TypeScript SDK does not wrap row writes yet
// (shipping in the next release). Use `fetch` for now.
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,
}),
});// The Go SDK does not wrap row writes yet
// (shipping in the next release). Use net/http for now.
body, _ := json.Marshal(map[string]any{
"id": "alice",
"handle": "@alice",
"bio": "Mechanical engineer. Trail runner. Reading sci-fi.",
"joined_ms": uint64(1746180851000),
})
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",
})
One row write on social.follows. The [[relations]] blocks on both columns make the forward and reverse edges land in the same commit - no second call.
curl -X POST "$ORIGINCHAIN_URL/v1/tenants/$T/rows/social.follows" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"follower_id": "alice",
"followee_id": "bob",
"created_ms": 1746180999000
}'# One row write on social.follows creates BOTH the
# forward (alice -follows-> bob) AND the reverse
# (bob -followed_by-> alice) edges in the same commit.
db.rows.put("social.follows", {
"follower_id": "alice",
"followee_id": "bob",
"created_ms": 1746180999000,
})await fetch(`${BASE_URL}/v1/tenants/${TENANT}/rows/social.follows`, {
method: "POST",
headers: {
"Authorization": `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
follower_id: "alice",
followee_id: "bob",
created_ms: 1746180999000,
}),
});body, _ := json.Marshal(map[string]any{
"follower_id": "alice",
"followee_id": "bob",
"created_ms": uint64(1746180999000),
})
req, _ := http.NewRequestWithContext(ctx, "POST",
BASE_URL+"/v1/tenants/"+TENANT+"/rows/social.follows",
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()
The user row and the profile embedding are separate calls - each atomic by itself. There is no single "write everything" endpoint. 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. The follow-edge write is its own atomic call, and writing the same follower/followee pair twice is a no-op because of the primary key.
- Writing both edge directions by hand. Don't. Let the two
[[relations]]blocks onsocial.followsdo it. Manually POSTing to/graph/edgeson top of the row write doubles every follow. - Updating handle without re-embedding. If you let users edit their handle and you embed it, the vector goes stale until you re-put. Either re-put on every profile edit or only embed the bio.
- Storing the follow edge inside the user row. Don't put
following: ["bob", "carol"]on the user row - it doesn't scale and you lose the reverse adjacency. Use the edge table. - Not deleting the edge on unfollow. Removing the user row doesn't fan out to their edges. You have to
DELETE /rows/social.follows/<follower_id>,<followee_id>separately.