Skip to content

Traverse

Traversals let you navigate relationships in your graph. Instead of writing complex SQL joins, describe the path you want to follow.

Follow one edge from a node to connected nodes:

const employments = await store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.id.eq("alice-123"))
.traverse("worksAt", "e") // Follow worksAt edges
.to("Company", "c") // Arrive at Company nodes
.select((ctx) => ({
person: ctx.p.name,
company: ctx.c.name,
role: ctx.e.role, // Edge properties are accessible
}))
.execute();
.traverse(edgeKind, edgeAlias, options?)
ParameterTypeDescription
edgeKindstringThe edge kind to traverse
edgeAliasstringUnique alias for referencing this edge
options.direction"out" | "in"Traversal direction (default: "out")
options.expand"none" | "implying" | "inverse" | "all"Ontology edge expansion mode (default: "inverse")
options.fromstringFan-out from a different node alias
.optionalTraverse(edgeKind, edgeAlias, options?)

Uses the same options as traverse(), but returns optional edge/node values in the result context.

.to(nodeKind, nodeAlias, options?)
ParameterTypeDescription
nodeKindstringThe target node kind
nodeAliasstringUnique alias for referencing this node
options.includeSubClassesbooleanInclude subclass kinds (default: false)

By default, traversals follow edges in their defined direction (from → to). Use direction: "in" to traverse backwards:

// Edge definition: worksAt goes from Person → Company
// Forward: Find companies where Alice works
.from("Person", "p")
.traverse("worksAt", "e") // Person → Company
.to("Company", "c")
// Backward: Find people who work at Acme
.from("Company", "c")
.whereNode("c", (c) => c.name.eq("Acme"))
.traverse("worksAt", "e", { direction: "in" }) // Company ← Person
.to("Person", "p")

Edges can carry properties. Access them through the edge alias:

const employments = await store
.query()
.from("Person", "p")
.traverse("worksAt", "e")
.to("Company", "c")
.select((ctx) => ({
person: ctx.p.name,
company: ctx.c.name,
role: ctx.e.role, // Edge property
salary: ctx.e.salary, // Edge property
startDate: ctx.e.startDate, // Edge property
}))
.execute();

Each edge provides these fields:

PropertyTypeDescription
idstringUnique edge identifier
kindstringEdge type name
fromIdstringID of the source node
toIdstringID of the target node
meta.createdAtstringWhen the edge was created
meta.updatedAtstringWhen the edge was last updated
meta.deletedAtstring | undefinedSoft delete timestamp
meta.validFromstring | undefinedTemporal validity start
meta.validTostring | undefinedTemporal validity end
schema propsvariesProperties defined in edge schema

Use whereEdge() to filter based on edge values:

const highPaying = await store
.query()
.from("Person", "p")
.traverse("worksAt", "e")
.whereEdge("e", (e) => e.salary.gte(100000))
.to("Company", "c")
.select((ctx) => ({
person: ctx.p.name,
company: ctx.c.name,
salary: ctx.e.salary,
}))
.execute();

Chain traversals to follow multiple relationships:

const projectTasks = await store
.query()
.from("Person", "person")
.whereNode("person", (p) => p.name.eq("Alice"))
.traverse("worksOn", "e1")
.to("Project", "project")
.traverse("hasTask", "e2")
.to("Task", "task")
.select((ctx) => ({
person: ctx.person.name,
project: ctx.project.name,
task: ctx.task.title,
}))
.execute();

Each hop starts from the previous node set and arrives at new nodes.

Combine forward and backward traversals:

const teamStructure = await store
.query()
.from("Person", "p")
.traverse("worksAt", "e1") // Forward: Person → Company
.to("Company", "c")
.traverse("manages", "e2", { direction: "in" }) // Backward: Person ← manages
.to("Person", "manager")
.select((ctx) => ({
employee: ctx.p.name,
company: ctx.c.name,
manager: ctx.manager.name,
}))
.execute();

Use optionalTraverse() for LEFT JOIN semantics—include results even when the traversal has no matches:

const peopleWithOptionalEmployer = await store
.query()
.from("Person", "p")
.optionalTraverse("worksAt", "e")
.to("Company", "c")
.select((ctx) => ({
person: ctx.p.name,
company: ctx.c?.name, // May be undefined if no employer
}))
.execute();
// Includes all people, even those without a worksAt edge
const employeesWithOptionalManager = await store
.query()
.from("Person", "p")
.traverse("worksAt", "e1") // Required: must work at a company
.to("Company", "c")
.optionalTraverse("reportsTo", "e2") // Optional: might not have manager
.to("Person", "manager")
.select((ctx) => ({
employee: ctx.p.name,
company: ctx.c.name,
manager: ctx.manager?.name, // undefined for top-level employees
}))
.execute();

With optional traversals, the edge may be undefined:

.select((ctx) => ({
person: ctx.p.name,
company: ctx.c?.name, // Node may be undefined
role: ctx.e?.role, // Edge may be undefined
salary: ctx.e?.salary,
}))

If your ontology defines edge implications, expand queries to include implying edges:

// Ontology: implies(marriedTo, knows), implies(bestFriends, knows)
const connections = await store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.name.eq("Alice"))
.traverse("knows", "e", { expand: "implying" })
.to("Person", "other")
.select((ctx) => ctx.other.name)
.execute();
// Returns people connected via "knows", "marriedTo", or "bestFriends"

If your ontology defines inverse edge kinds, you can expand traversals to include inverse edges:

// Ontology: inverseOf(manages, managedBy)
const relationships = await store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.name.eq("Alice"))
.traverse("manages", "e", { expand: "inverse" })
.to("Person", "other")
.select((ctx) => ({
name: ctx.other.name,
viaEdgeKind: ctx.e.kind,
}))
.execute();
// Traverses both "manages" and "managedBy"

You can combine both options:

.traverse("knows", "e", { expand: "all" })

For kinds and edges added at runtime via graph extensions, use the string-keyed siblings fromDynamic (covered on Source), traverseDynamic, optionalTraverseDynamic, and toDynamic:

const rows = await store
.query()
.fromDynamic("Paper", "p")
.traverseDynamic("authoredBy", "a")
.toDynamic("Author", "u")
.whereNode("p", (p) => p.field("year").number().gte(2020))
.select((ctx) => ({ paper: ctx.p, author: ctx.u, edge: ctx.a }))
.execute();

Each method runtime-validates against the registry — typos throw KindNotFoundError, and a toDynamic target that isn’t a valid endpoint for the current edge / direction throws EndpointError.

Predicate accessors on dynamic-declared aliases expose schema properties through a .field(name) discriminator. BaseFieldAccessor methods (eq, isNull, in, notIn) work directly. Type-specific predicates sit behind one of:

DiscriminatorReturns
.string()StringFieldAccessor (gte, contains, like, …)
.number()NumberFieldAccessor (gte, between, …)
.date()DateFieldAccessor
.array()ArrayFieldAccessor<unknown>
.object()ObjectFieldAccessor<...>
.embedding()EmbeddingFieldAccessor (similarTo)

Each discriminator validates against the registered Zod schema at query-build time and throws TypeError on mismatch:

.whereNode("p", (p) => p.field("year").number().gte(2020)) // ✓
.whereNode("p", (p) => p.field("year").string().eq("2020")) // throws TypeError
.whereNode("p", (p) => p.field("yera").number().gte(2020)) // throws (unknown property)
// BaseFieldAccessor methods don't need a discriminator.
.whereNode("p", (p) => p.field("year").isNotNull()) // ✓

The same .field() API is available on edge accessors:

.whereEdge("a", (e) => e.field("order").number().eq(1))

Typed and dynamic aliases interleave in one query. Each alias’s predicate accessor is resolved independently — typed aliases keep their narrow accessors, dynamic aliases get .field():

const rows = await store
.query()
.from("Document", "d") // compile-time kind
.traverseDynamic("taggedWith", "e") // runtime edge
.toDynamic("Tag", "n") // runtime target
.whereNode("d", (d) => d.title.eq("the doc")) // typed: direct
.whereNode("n", (n) => n.field("label").string().eq("research")) // dynamic
.select((ctx) => ({ doc: ctx.d, tag: ctx.n }))
.execute();

A typed traverse("knownEdge", "e") followed by toDynamic(target, "n") keeps e typed — e.role.eq(...) works without .field() because the edge schema is known at compile time. Only aliases declared via fromDynamic / traverseDynamic / optionalTraverseDynamic / toDynamic go through the discriminator.

optionalTraverseDynamic is the LEFT-JOIN sibling. Source nodes without a matching edge still surface, with the edge and target aliases as undefined:

const papersWithOptionalAuthor = await store
.query()
.fromDynamic("Paper", "p")
.optionalTraverseDynamic("authoredBy", "a")
.toDynamic("Author", "u")
.select((ctx) => ({
paperTitle: ctx.p.title,
authorName: ctx.u?.name, // undefined for orphan papers
order: ctx.a?.order,
}))
.execute();
const teamMembers = await store
.query()
.from("Person", "manager")
.whereNode("manager", (p) => p.name.eq("VP Engineering"))
.traverse("manages", "e")
.to("Person", "report")
.select((ctx) => ({
manager: ctx.manager.name,
report: ctx.report.name,
department: ctx.report.department,
}))
.execute();
const friends = await store
.query()
.from("Person", "me")
.whereNode("me", (p) => p.id.eq(currentUserId))
.traverse("follows", "e")
.to("Person", "friend")
.select((ctx) => ({
id: ctx.friend.id,
name: ctx.friend.name,
followedAt: ctx.e.createdAt,
}))
.orderBy("e", "createdAt", "desc")
.limit(50)
.execute();
const orderDetails = await store
.query()
.from("Order", "o")
.whereNode("o", (o) => o.id.eq(orderId))
.traverse("contains", "e")
.to("Product", "p")
.select((ctx) => ({
product: ctx.p.name,
quantity: ctx.e.quantity,
unitPrice: ctx.e.unitPrice,
}))
.execute();
  • Recursive - Variable-length paths with recursive()
  • Filter - Filter nodes and edges with predicates
  • Shape - Transform output with select()