Skip to content
DocsconceptsDocumentation

Relation graph

Traverse the foreign-key graph your schema already declares with ctx.db.related — depth-bounded, cycle-safe, scored by proximity, and fusable into hybrid retrieval.

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 holds customerId, a message holds ticketId. "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.authorId is 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 same limit.
  • limit: 100 — one page, not the whole history. isDone says 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 declarationEdge nameDirection of the data
tickets: { customerId: v.id("customers") }tickets.customerIdticket → customer
messages: { ticketId: v.id("tickets") }messages.ticketIdmessage → ticket
tickets: { tagIds: v.array(v.id("tags")) }tickets.tagIdsticket → 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.

OptionDefaultCapMeaning
depth14How many hops to expand.
limit50200Maximum 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 with hop 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 with edges.
  • 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: truerelated receives options.filter and 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 matrixemulated 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.