Last updated:
Every v.id("customers") column in your schema is an edge: it says a ticket
points at a customer. Collect all of them and you have a directed graph, and
your schema already declares it — you do not add anything to opt in.
ctx.db.related(...) walks that graph. It answers "what is this connected
to", which neither keyword nor semantic search can: the connection lives in a
foreign key, not in the text.
import { query, v } from "@/lunora/_generated/server";
export const customerContext = query.input({ customerId: v.id("customers") }).query(async ({ ctx, args: { customerId } }) => {
const { nodes } = await ctx.db.related({ table: "customers", id: customerId }, { depth: 2 });
return nodes.map((node) => ({ depth: node.depth, id: node.document._id, path: node.path, table: node.table }));
});From a customer, depth 2 reaches its tickets and those tickets' messages — a
walk you would otherwise hand-write as a chain of findMany calls with the join
columns typed in yourself.
This is not .relations(). A relation descriptor is something you declare — a named, typed one / many accessor
that with hydrates one hop at a time, carrying cardinality and onDelete behaviour. The relation
graph is derived: it reads every v.id(...) column in the schema whether or not a descriptor names it, and walks several hops at once. Reach for
with when you know which relationship you want and want it typed; reach for related when the question is "what is connected to this" and the answer
spans tables you did not name up front.
A worked example
A support desk: a customer has tickets, and a ticket has messages. Two
v.id(...) columns declare the entire graph — there is no join table and no
relation descriptor.
// lunora/schema.ts
export default defineSchema({
customers: defineTable({ email: v.string(), name: v.string() }),
tickets: defineTable({
customerId: v.id("customers"),
status: v.union(v.literal("open"), v.literal("closed")),
subject: v.string(),
}).index("by_customer", ["customerId"]),
messages: defineTable({
authorId: v.id("customers"),
body: v.string(),
ticketId: v.id("tickets"),
}).index("by_ticket", ["ticketId"]),
});That is three edges — tickets.customerId, messages.ticketId and
messages.authorId. Walking two hops inward from a customer reaches its
tickets at depth 1 and those tickets' messages at depth 2:
import { query, v } from "@/lunora/_generated/server";
export const customerContext = query.input({ customerId: v.id("customers") }).query(async ({ ctx, args: { customerId } }) => {
const { isDone, nodes } = await ctx.db.related(
{ table: "customers", id: customerId },
{ depth: 2, direction: "in", edges: ["tickets.customerId", "messages.ticketId"], limit: 100 },
);
return {
hasMore: !isDone,
messages: nodes.filter((node) => node.table === "messages").map((node) => node.document),
tickets: nodes.filter((node) => node.table === "tickets").map((node) => node.document),
};
});Each option is load-bearing:
direction: "in"— both edges point inward toward the start row: a ticket holdscustomerId, a message holdsticketId."in"is the "children of" direction."out"would walk the other way — from a message to its ticket, and from that ticket to its customer.edges— pins the walk to the two hops this feature wants.messages.authorIdis an edge too, so without the allow-list depth 1 would also pull in every message this customer authored anywhere — rows that then compete with the tickets for the samelimit.limit: 100— one page, not the whole history.isDonesays whether there is more; see Cycles and paging.
Every node says how it was reached, which is what makes the result usable as model context rather than an unattributed bag of rows:
{
table: "messages",
depth: 2,
score: 0.5,
path: ["tickets.customerId", "messages.ticketId"],
pathIds: ["c_1", "t_9", "m_42"],
document: { _id: "m_42", body: "…", ticketId: "t_9", authorId: "c_1" },
}You can also start from a row you already have in hand — pass the document
itself instead of { table, id }, and its table is resolved from the _id it
carries. A .global() row is the exception: its table lives in D1, out of reach
of that lookup, so pass { table, id } for those.
const ticket = await ctx.db.get(ticketId);
const { nodes } = await ctx.db.related(ticket, { depth: 1, direction: "out" });The edge set
One edge per v.id("target") column, named `<table>.<column>` so it is
addressable:
| Column declaration | Edge name | Direction of the data |
|---|---|---|
tickets: { customerId: v.id("customers") } | tickets.customerId | ticket → customer |
messages: { ticketId: v.id("tickets") } | messages.ticketId | message → ticket |
tickets: { tagIds: v.array(v.id("tags")) } | tickets.tagIds | ticket → many tags |
v.optional(v.id(...)) (a nullable foreign key) and v.array(v.id(...)) (a
to-many one) are both edges. An id nested inside a v.object, v.union or
v.record is not: it is not a column the query layer can filter on, so an
edge named for it would describe a hop no read could take. An edge whose target
table the schema does not declare is dropped for the same reason.
Directions
direction picks which way the edges are followed out of each visited row:
"out"— follow the ids this row holds. From a ticket: its customer, its tags."in"— follow the rows that point at it. From a customer: its tickets."both"(the default) — the union of the two.
// Just this customer's tickets, nothing else.
await ctx.db.related({ table: "customers", id }, { direction: "in", edges: ["tickets.customerId"] });edges restricts the walk to the named edge types. A name the schema does not
declare is refused, not ignored — a typo that silently widened a traversal
is worse than one that fails.
An array foreign key (v.array(v.id("tags"))) is followed outward only. where has no array-containment operator, so finding the rows whose array
column contains an id would mean scanning the holder table and filtering in memory, per node, per hop. An unbounded scan is worse than a missing hop. To
walk that direction, model the join as its own table with two scalar v.id(...) columns — which gives you two ordinary edges.
Depth, limit and score
The walk is breadth-first, so every depth-1 neighbour is emitted before any depth-2 one, and the page is already ordered by the score it carries.
| Option | Default | Cap | Meaning |
|---|---|---|---|
depth | 1 | 4 | How many hops to expand. |
limit | 50 | 200 | Maximum nodes in one page. |
Both caps are refusals, not silent clamps. A caller asking for depth 9 has a wrong mental model of the cost, and quietly serving them depth 4 hides it.
Each node carries a depth-decaying score: 1 at depth 1, halving per hop
(0.5 ** (depth - 1)). Halving rather than 1 / depth because the useful
property is that distant evidence cannot out-weigh a direct neighbour.
for (const node of nodes) {
node.table; // "tickets"
node.document; // the row itself
node.depth; // 1
node.score; // 1
node.path; // ["tickets.customerId"] — the edge names walked
node.pathIds; // ["c_1", "t_9"] — the ids along the way, start included
}path and pathIds are what make a result explainable: they say why a row
came back, which is the difference between a citation and a guess when the
consumer is a model.
Cycles and paging
A visited set makes a cyclic schema terminate at the first revisit, so
customers.primaryTicketId → tickets paired with tickets.customerId → customers expands once and stops rather than forever. Each row is returned at
most once, by its shortest path.
Paging uses the same { isDone, continueCursor } envelope as every other
ctx.db read:
let cursor: null | string = null;
do {
const page = await ctx.db.related({ table: "customers", id }, { cursor, depth: 2, limit: 25 });
consume(page.nodes);
cursor = page.continueCursor;
} while (cursor !== null);The cursor is an offset, not a keyset: a breadth-first expansion has no single ordered column to seek on, so page N+1 re-walks deterministically and skips what page N returned.
That re-walk is work, not a skip — a page at offset N makes each hop read
N + limit + 1 rows — so the offset carries its own ceiling of 10 000
(50 pages at the maximum limit), refused rather than clamped like every other
bound here. A cursor is unsigned, so without that ceiling limit's cap was
bypassable simply by moving the number into the cursor. Paging that far means
the traversal is the wrong tool: narrow the walk with edges or direction
instead.
It is an ordinary read
related emits no SQL of its own. Every hop goes back through ctx.db's own
reads — one batched findMany per edge per hop — which is what keeps the rest
of the framework applying to it:
- Row-level security — the caller's per-table read policy filters the start
row and every hop, through the same seam a
withhop rides. A start row the policy hides reads as absent. Under a.rls("required")schema each hop gets exactly the verdict a direct read of that table would: a table your procedure declares a read policy for is reachable and policy-filtered, a.public()one is readable, and a protected table you declared no read policy for is denied — so declare a policy for every table the walk can reach, or narrow it withedges. - Column masking — each hop's rows are masked with that hop's table policy.
- Soft delete — deleted rows stay hidden.
.global()tables — a hop into one is routed to the global backend.- Realtime — a live query running a traversal re-runs when the rows it walked change.
Fusing the graph into retrieval
A relation graph is a third retrieval signal, alongside the vector (semantic)
and lexical (keyword) legs of defineRag. Set
graphStore and retrieve() seeds a traversal from the source documents the
search legs found, then fuses the connected documents' chunks into the same
Reciprocal Rank Fusion ranking — each hit's contribution scaled by its depth
decay, so a direct neighbour weighs more than a distant one at the same rank.
import { defineRag } from "@lunora/ai/rag";
const docs = defineRag({
index: "docs",
lexicalStore: bm25LexicalStore(),
graphStore: {
// Every returned document is matched against the effective filter below,
// so this store may declare that it enforces it. See the note that follows.
enforcesFilter: true,
// The RAG source ids ARE document ids here, so a seed is a start node.
related: async (sourceIds, { filter, topK }) => {
const hits = [];
for (const id of sourceIds) {
const { nodes } = await ctx.db.related({ table: "tickets", id }, { depth: 2, limit: topK });
for (const node of nodes) {
// The filter `retrieve()` applied to the vector and lexical
// legs, applied here too — otherwise a neighbour outside it
// rides in on a document that was inside it.
if (Object.entries(filter ?? {}).every(([key, value]) => node.document[key] === value)) {
hits.push({ id: `${node.document._id}#0`, score: node.score, text: String(node.document.body) });
}
}
}
return hits.slice(0, topK);
},
},
});The graph leg widens a ranking rather than producing one: it has no notion
of a query string, so it is seeded from what the search legs already found. A
chunk the vector leg rejected for minScore stays rejected — being
graph-adjacent does not re-admit it.
The filter reaches the graph leg too
retrieve() hands all three legs the same effective filter — the caller's
filter with rlsFilter merged over it. The vector and lexical stores enforce
it; a graph store has to say whether it does, because nothing in defineRag can
tell by looking. That is what enforcesFilter is for, and it is required:
enforcesFilter: true—relatedreceivesoptions.filterand is trusted to apply it to every document it returns.enforcesFilter: false— the graph leg is skipped entirely whenever a filter is in play. Retrieval keeps its vector and lexical legs and loses its third signal, rather than returning a neighbour the filter would have excluded.
With no filter at all there is nothing to enforce and false costs nothing. If
you are unsure whether your traversal really narrows, answer false: a missing
signal is recoverable, a leaked tenant is not.
From an AI agent
The @lunora/mcp server exposes the same traversal as the lunora_find_related
tool, so an agent can follow relationships without your app writing a query for
it. It takes table and id plus the same depth, direction, edges,
limit and cursor options — plus a shardKey to pick the shard on a
.shardBy() deployment — and returns the same nodes.
The MCP tool does not inherit the guarantees above. It is off by default and needs its own gate, LUNORA_MCP_ALLOW_DATA_READS, because it does not run
one of your declared functions the way lunora_run_query does — it reaches the shard through the deployment's admin writer, with RLS policies and
column masks bypassed. So it returns raw rows out of arbitrary tables, plus everything reachable within depth hops of them, straight into a model's
context. Holding the admin bearer already confers that authority; enabling this gate is the separate decision to hand it to a model. Leave it off unless the
rows in question are ones you would paste into the chat yourself. See @lunora/mcp.
Platform support
relationGraph is rated in the capability
matrix — emulated on both Cloudflare and Node,
because the traversal is Lunora's own expansion over reads the host already
serves rather than a graph engine being consumed. It rides the ShardHost
contract (the shard's SQL handle) and needs nothing beyond it. A target that
rates it unsupported makes codegen refuse an app whose schema declares any
v.id(...) column, rather than emitting a related that fails on the first hop.