
Helix Query Rust
- 125 installs
- Updated August 3, 2026
- helixdb/skills
Writes and reviews HelixDB queries in the Rust DSL with traversals, projections, indexes, and vector or BM25 search.
About
Writes and revises HelixDB Rust DSL queries from scratch with traversal builders, projections, indexes, and BM25/vector search. A developer uses it to author or review Helix queries in a Rust codebase.
- Uses read_batch/write_batch, #[register], and queries.json bundles
- Preferred way to author Helix queries in a Rust codebase
Helix Query Rust by the numbers
- 125 all-time installs (skills.sh)
- +12 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #61 of 121 Rust skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/helixdb/skills --skill helix-query-rustAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 125 |
|---|---|
| Last updated | August 3, 2026 |
| Repository | helixdb/skills ↗ |
What it does
Writes and reviews HelixDB queries in the Rust DSL with traversals, projections, indexes, and vector or BM25 search.
Files
Helix Query Authoring — Rust
Write Helix Rust DSL queries in a way that is schema-aware, explicit, and easy for agents to reason about. The Rust builder is the helix-db crate (sdks/rust); the TypeScript DSL (helix-query-typescript) emits the same JSON AST.
This is the preferred way to author Helix queries in a Rust codebase. Drop to raw dynamic JSON (helix-query-json-dynamic) only for debugging or dynamically-shaped requests.
When To Use
Use this skill when the task is to:
- write a new Helix query in Rust
- revise an existing Helix Rust DSL route
- bundle queries into a
queries.json - choose between
read_batch()andwrite_batch() - add traversal, projection, pagination, BM25 search, or vector search to an existing query
Do not use this skill as the main guide for inline POST /v1/query payloads — use helix-query-json-dynamic. For the TypeScript DSL, use helix-query-typescript.
First Steps
Before writing any query code:
1. Inspect the local repo for existing labels, edge labels, properties, and route patterns. 2. Find the closest existing query and reuse its naming, projection, and scoping style. 3. Decide whether the route is a read or a write. 4. Identify the narrowest indexed anchor before planning the traversal.
If the local repo is thin on Helix examples, use the companion files in this skill:
1. EXAMPLES.md — working end-to-end Rust queries (reads, writes, search, repeat, branching, upsert, for_each_param). 2. REFERENCE.md — full builder catalog organized by category, with typestate notes.
Open REFERENCE.md whenever you need a builder beyond the common surface (add_e, drop_edge_by_id, create_vector_index_nodes, repeat, choose, coalesce, optional, aggregate_by, group_count, inject, order_by_multiple, expression case, etc.) — do not invent method names from memory.
Core Authoring Rules
1. Start With The Right Batch Type
Use:
read_batch()for read-only routeswrite_batch()for any mutation
If the query adds nodes, adds edges, updates properties, or deletes graph data, it is a write route.
2. Anchor Narrow, Then Traverse
Prefer this anchor order:
1. node ID or edge ID 2. unique property lookup 3. equality-indexed property lookup 4. scoped label scan 5. broad label scan as a last resort
Do not start from a broad label scan when the application already has an indexed identifier like entityId, externalId, userId, tenantId, or a similar key.
3. Reuse Existing Property And Label Casing
Do not normalize names to your own preferred style.
If the application uses entityId, updatedAt, FOLLOWS, or RelatesTo, reuse those exact names.
4. Filter Early
Apply scope and status filters before broad traversal whenever possible.
Common examples:
- tenant filters like
tenantIdoruserId - soft-delete or archived filters such as empty or null
deletedAt - specific ID filters before
both,out, orin_
5. Keep Output Shape Intentional
Use:
project(...)for stable service-facing response shapesvalue_map(...)when returning all or many properties is acceptableedge_properties()for edge streams- For edge endpoint properties, prefer edge-stream
project(...)with
Projection::from_endpoint(prop, alias) / Projection::to_endpoint(prop, alias) instead of traversing to every endpoint first.
Do not return oversized properties like embeddings unless the caller explicitly needs them.
6. Preserve Search Scope
For BM25 and vector search:
- keep the chosen text or vector property explicit
- preserve tenant scope when the index is scoped
- post-filter only when the search API cannot express the scope directly
7. Use Traversal Controls Deliberately
Apply dedup, limit, range, skip, count, and first because the route needs them, not by habit.
repeat(...) is often used with a deliberate bounded depth. Do not assume arbitrary runtime repeat depth unless the local code already supports it.
8. Prefer Explicit Write Branching Over Invented MERGE Semantics
When you need create-or-update behavior, follow this pattern:
1. load existing nodes 2. branch with var_as_if 3. update when found 4. create when missing
9. Know The Full Builder Surface
The DSL is larger than the canonical examples below suggest. Before reaching for a workaround, check REFERENCE.md — there is likely a direct builder.
| Category | Primary builders | Notes |
|---|---|---|
| Sources | g().n(...), n_where, n_with_label, n_with_label_where, e, e_where, e_with_label, e_with_label_where, vector_search_nodes_with, text_search_nodes_with, vector_search_edges_with, text_search_edges_with | Anchor narrowly — indexed ID first, then label scope. |
| Traversal | out, in_, both, out_e, in_e, both_e, out_n, in_n, other_n | Edge-valued forms (*_e) switch the stream type. |
| Filters | has, has_label, has_key, where_, dedup, within, without, edge_has, edge_has_label | Predicate::* + Predicate::*_param for parameterized comparisons. |
| Limits | limit, skip, range | All accept usize or Expr. |
| Variables | as_ / store, select, inject | Cross-query refs via NodeRef::var, EdgeRef::var, NodeRef::param, EdgeRef::param. |
| Ordering | order_by, order_by_multiple | Use Order::Desc for descending. |
| Aggregation | count, exists, group, group_count, aggregate_by | AggregateFunction::{Count,Sum,Min,Max,Mean}. |
| Branching | union, choose, coalesce, optional | Each arm is a sub() sub-traversal. |
| Repeat | repeat(RepeatConfig::new(sub).times(n).until(pred).emit_all().max_depth(100)) | Always bound with times or until; default max_depth is 100. |
| Projection | values, value_map, project, edge_properties | project mixes PropertyProjection (incl. renames) and ExprProjection; edge streams can project endpoint fields with Projection::from_endpoint / Projection::to_endpoint. |
| Expressions | Expr::prop, Expr::val, Expr::id, Expr::timestamp, Expr::datetime, Expr::param, .add/.sub/.mul/.div/.modulo/.neg, Expr::case | Expr::Timestamp writes server UTC millis; Expr::DateTimeNow writes typed datetime. |
| Mutations | add_n, add_e, set_property, remove_property, drop, drop_edge, drop_edge_labeled, drop_edge_by_id | drop_edge_by_id is multigraph-safe. |
| Indexes | IndexSpec::node_equality / node_range / node_range_desc / node_range_with_direction / edge_equality / edge_range / edge_range_desc / edge_range_with_direction / node_vector / node_text / edge_vector / edge_text plus create_index / drop_index; convenience: create_vector_index_nodes, create_text_index_nodes, edge variants | Use .create_index(spec) from a write batch. RangeIndexDirection::Desc sets descending physical order. |
| Transport | DynamicQueryRequest::{read,write}(batch).with_query_name("name").with_parameter_value(...).with_parameter_type(...).to_json_string() | Bridge from Rust DSL to the JSON payload (helix-query-json-dynamic). Direct unnamed requests serialize query_name: null; #[register] callable helpers set query_name to the Rust function name. |
| Client | Client::new(Some(url))?.with_api_key(...).query().writer_only()/.warm_only()/.should_await_durability(b).dynamic(req)/.stored(name).send().await | Sends to POST /v1/query; send() yields R on 200, else HelixError. Prefer .should_await_durability(true) on writes — reduces 409 conflicts under concurrency. See REFERENCE.md → "Client". |
See REFERENCE.md for signatures and typestate constraints.
Nested object/array property values are supported with PropertyValue::object(...) and PropertyValue::array(...). Read nested object fields with dotted property strings such as metadata.externalID in predicates, Expr::prop, values, value_map, project, and order_by. Dotted paths are exact-first and scan-only in V1; indexes remain top-level only.
Canonical Examples
Read By Indexed Identifier
read_batch()
.var_as(
"user",
g().n_with_label("User")
.where_(Predicate::eq_param("userId", "userId"))
.project(vec![
PropertyProjection::new("$id"),
PropertyProjection::new("userId"),
PropertyProjection::new("name"),
]),
)
.returning(["user"])Explicit Create Or Update
write_batch()
.var_as(
"existing",
g().n_with_label("User")
.where_(Predicate::eq_param("userId", "userId")),
)
.var_as_if(
"updated",
BatchCondition::VarNotEmpty("existing".to_string()),
g().n(NodeRef::var("existing"))
.set_property("name", PropertyInput::param("name")),
)
.var_as_if(
"created",
BatchCondition::VarEmpty("existing".to_string()),
g().add_n(
"User",
vec![
("userId", PropertyInput::param("userId")),
("name", PropertyInput::param("name")),
],
),
)
.returning(["updated", "created"])Scoped Search Route
read_batch()
.var_as(
"results",
g().vector_search_nodes_with(
"Document",
"embedding",
PropertyInput::param("queryVector"),
Expr::param("limit"),
Some(PropertyInput::param("tenantId")),
)
.project(vec![
PropertyProjection::new("$id"),
PropertyProjection::new("title"),
PropertyProjection::renamed("$distance", "distance"),
]),
)
.returning(["results"])Anti-Patterns
Do not:
- invent labels, edge labels, or property names without checking the codebase
- start from broad scans when an indexed ID or scoped predicate exists
- return embeddings by default in search results
- ignore tenant scope on text or vector search
- add
deduporlimitwithout a reason - assume dynamic inline-query rules apply to Rust DSL queries authored with the builder
- treat BM25 as if it searches every property automatically
Validation Checklist
Before finishing:
- verify
read_batch()versuswrite_batch()is correct - verify labels, edge labels, and properties match the repo exactly
- verify the first anchor is the narrowest practical indexed set
- verify scope filters happen before or as early as possible
- verify the returned variable names and shape match service expectations
- verify text and vector routes preserve tenant scope when required
- verify large properties are omitted unless needed
- verify the query matches surrounding local style more than any generic example
Reference Files
REFERENCE.md— full builder catalog (sources, traversal, predicates, expressions, projections, branching, repeat, mutations, indexes, dynamic-request transport).EXAMPLES.md— end-to-end Rust queries mirroring the scenarios in../helix-query-typescript/EXAMPLES.mdand../helix-query-json-dynamic/EXAMPLES.md1:1, so you can move fluently between the Rust DSL, TypeScript DSL, and JSON forms.
Helix Query Authoring — Rust Examples
Each numbered scenario corresponds 1:1 with ../helix-query-typescript/EXAMPLES.md and ../helix-query-json-dynamic/EXAMPLES.md. When moving between the Rust DSL, TypeScript DSL, and inline JSON, open the same scenario in each file.
All snippets assume use helix_db::dsl::prelude::*;.
Calling a public #[register] function returns a DynamicQueryRequest whose top-level query_name is the Rust function name. Direct DynamicQueryRequest::read/write builders serialize query_name: null until .with_query_name(...) or .set_query_name(...) is used.
---
1. Count nodes matching label + predicate
#[register]
pub fn active_user_count() -> ReadBatch {
read_batch()
.var_as(
"active_count",
g().n_with_label("User")
.where_(Predicate::eq("status", "active"))
.count(),
)
.returning(["active_count"])
}---
2. Read node by indexed property with projection
Literal form:
#[register]
pub fn user_by_id_literal() -> ReadBatch {
read_batch()
.var_as(
"user",
g().n_with_label_where(
"User",
SourcePredicate::eq("userId", "u-42"),
)
.project(vec![
PropertyProjection::renamed("$id", "id"),
PropertyProjection::new("userId"),
PropertyProjection::new("name"),
]),
)
.returning(["user"])
}Parameterized form (preferred):
#[register]
pub fn user_by_id(userId: String) -> ReadBatch {
let _ = &userId;
read_batch()
.var_as(
"user",
g().n_with_label("User")
.where_(Predicate::eq_param("userId", "userId"))
.project(vec![
PropertyProjection::renamed("$id", "id"),
PropertyProjection::new("name"),
]),
)
.returning(["user"])
}---
3. Multi-hop traversal with dedup + limit
#[register]
pub fn friends_of_friends(userId: Vec<i64>) -> ReadBatch {
let _ = &userId;
read_batch()
.var_as(
"fof",
g().n(NodeRef::param("userId"))
.out(Some("FOLLOWS"))
.out(Some("FOLLOWS"))
.dedup()
.limit(50usize)
.values(vec!["$id", "name"]),
)
.returning(["fof"])
}---
4. Vector search with tenant + distance in projection
#[register]
pub fn nearest_documents(
tenantId: String,
queryVector: Vec<f64>,
k: i64,
) -> ReadBatch {
let _ = (&tenantId, &queryVector, &k);
read_batch()
.var_as(
"hits",
g().vector_search_nodes_with(
"Document",
"embedding",
PropertyInput::param("queryVector"),
Expr::param("k"),
Some(PropertyInput::param("tenantId")),
)
.project(vec![
PropertyProjection::renamed("$id", "id"),
PropertyProjection::new("title"),
PropertyProjection::renamed("$distance", "distance"),
]),
)
.returning(["hits"])
}Project $distance before any .out/.in_/.both — traversal off the hit stream drops the distance metadata.
---
5. BM25 text search with post-filter
#[register]
pub fn document_search(
tenantId: String,
q: String,
) -> ReadBatch {
let _ = (&tenantId, &q);
read_batch()
.var_as(
"results",
g().text_search_nodes_with(
"Document",
"body",
PropertyInput::param("q"),
50usize,
Some(PropertyInput::param("tenantId")),
)
.where_(Predicate::eq("published", true))
.limit(10usize)
.project(vec![
PropertyProjection::renamed("$id", "id"),
PropertyProjection::new("title"),
PropertyProjection::renamed("$distance", "score"),
]),
)
.returning(["results"])
}---
6. Repeat traversal with until + emit_after
#[register]
pub fn management_chain(startId: Vec<i64>) -> ReadBatch {
let _ = &startId;
read_batch()
.var_as(
"chain",
g().n(NodeRef::param("startId"))
.repeat(
RepeatConfig::new(sub().out(Some("REPORTS_TO")))
.until(Predicate::eq("title", "CEO"))
.emit_after()
.max_depth(10),
)
.project(vec![
PropertyProjection::renamed("$id", "id"),
PropertyProjection::new("name"),
PropertyProjection::new("title"),
]),
)
.returning(["chain"])
}---
7. Union of two sub-traversals
#[register]
pub fn user_network(userId: Vec<i64>) -> ReadBatch {
let _ = &userId;
read_batch()
.var_as(
"network",
g().n(NodeRef::param("userId"))
.union(vec![
sub().out(Some("FOLLOWS")),
sub().in_(Some("FOLLOWS")),
])
.dedup()
.values(vec!["$id", "name"]),
)
.returning(["network"])
}---
8. Choose (conditional traversal)
#[register]
pub fn user_content(userId: Vec<i64>) -> ReadBatch {
let _ = &userId;
read_batch()
.var_as(
"content",
g().n(NodeRef::param("userId"))
.choose(
Predicate::eq("tier", "premium"),
sub().out(Some("HAS_PREMIUM")),
Some(sub().out(Some("HAS_FREE"))),
)
.limit(20usize)
.value_map(Some(vec!["$id", "title"])),
)
.returning(["content"])
}---
9. Coalesce (fallback traversal)
#[register]
pub fn preferred_team(userId: Vec<i64>) -> ReadBatch {
let _ = &userId;
read_batch()
.var_as(
"team",
g().n(NodeRef::param("userId"))
.coalesce(vec![
sub().out(Some("PREFERRED_TEAM")),
sub().out(Some("PRIMARY_TEAM")),
sub().out(Some("MEMBER_OF")).limit(1usize),
])
.values(vec!["$id", "name"]),
)
.returning(["team"])
}---
10. Project with Expr::case (computed field)
#[register]
pub fn users_with_bucket() -> ReadBatch {
read_batch()
.var_as(
"users",
g().n_with_label("User").project(vec![
Projection::property("$id", "id"),
Projection::property("score", "score"),
Projection::expr(
"bucket",
Expr::case(
vec![
(
Predicate::gte("score", 1000i64),
Expr::val("high"),
),
(
Predicate::gte("score", 100i64),
Expr::val("mid"),
),
],
Some(Expr::val("low")),
),
),
]),
)
.returning(["users"])
}---
11. Aggregation: group_count and aggregate_by
#[register]
pub fn users_by_status() -> ReadBatch {
read_batch()
.var_as(
"by_status",
g().n_with_label("User").group_count("status"),
)
.returning(["by_status"])
}
#[register]
pub fn total_revenue() -> ReadBatch {
read_batch()
.var_as(
"revenue",
g().n_with_label("Order")
.aggregate_by(AggregateFunction::Sum, "price"),
)
.returning(["revenue"])
}---
Edge Endpoint Projection
Use this when an edge list needs stable source/target resource ids. It keeps one output row per edge and avoids traversing to every endpoint node.
#[register]
pub fn list_describes_relationships() -> ReadBatch {
read_batch()
.var_as(
"relationships",
g().e_with_label("DESCRIBES").project(vec![
Projection::from_endpoint("resource_id", "from_id"),
Projection::to_endpoint("resource_id", "to_id"),
Projection::property("$id", "edge_id"),
Projection::property("confidence", "confidence"),
]),
)
.returning(["relationships"])
}Wire format:
{"Project": [
{"source": "$from.resource_id", "alias": "from_id"},
{"source": "$to.resource_id", "alias": "to_id"},
{"source": "$id", "alias": "edge_id"},
{"source": "confidence", "alias": "confidence"}
]}---
12. Write: add_n + add_e in one batch with cross-entry Var reference
#[register]
pub fn create_user_and_link_post(
userId: String,
name: String,
postId: Vec<i64>,
) -> WriteBatch {
let _ = (&userId, &name, &postId);
write_batch()
.var_as(
"newUser",
g().add_n(
"User",
vec![
("userId", PropertyInput::param("userId")),
("name", PropertyInput::param("name")),
("createdAt", PropertyInput::from(Expr::timestamp())),
],
)
.project(vec![PropertyProjection::renamed("$id", "id")]),
)
.var_as(
"link",
g().n(NodeRef::param("postId"))
.add_e::<&str, PropertyInput>("CREATED_BY", NodeRef::var("newUser"), vec![]),
)
.returning(["newUser", "link"])
}---
13. Write: upsert via var_as_if
#[register]
pub fn upsert_user(userId: String, name: String) -> WriteBatch {
let _ = (&userId, &name);
write_batch()
.var_as(
"existing",
g().n_with_label("User")
.where_(Predicate::eq_param("userId", "userId")),
)
.var_as_if(
"updated",
BatchCondition::VarNotEmpty("existing".to_string()),
g().n(NodeRef::var("existing"))
.set_property("name", PropertyInput::param("name")),
)
.var_as_if(
"created",
BatchCondition::VarEmpty("existing".to_string()),
g().add_n(
"User",
vec![
("userId", PropertyInput::param("userId")),
("name", PropertyInput::param("name")),
],
),
)
.returning(["updated", "created"])
}---
14. Write: for_each_param over an array of objects
#[register]
pub fn bulk_create_users(data: Vec<ParamObject>) -> WriteBatch {
let _ = &data;
let body = write_batch().var_as(
"created",
g().add_n(
"User",
vec![
("externalId", PropertyInput::param("externalId")),
("embedding", PropertyInput::param("embedding")),
],
),
);
write_batch()
.for_each_param("data", body)
.returning(["created"])
}Inside body, the parameter names resolve against each object's fields. Registering with data: Vec<ParamObject> makes the macro record QueryParamType::Array(Box::new(QueryParamType::Object)), which is exactly {"Array": "Object"} on the wire.
---
15. Nested object properties + dotted paths
#[register]
pub fn create_user_with_metadata() -> WriteBatch {
let metadata = PropertyValue::object(vec![
("externalID", PropertyValue::from("crm-42")),
("score", PropertyValue::from(20i64)),
(
"tags",
PropertyValue::array(vec![
PropertyValue::from("trial"),
PropertyValue::from(7i64),
]),
),
]);
write_batch()
.var_as(
"user",
g().add_n(
"User",
vec![
("userId", PropertyInput::from("u-42")),
("metadata", PropertyInput::from(metadata)),
],
)
.value_map(Some(vec!["userId", "metadata.externalID"])),
)
.returning(["user"])
}
#[register]
pub fn users_by_external_id() -> ReadBatch {
read_batch()
.var_as(
"users",
g().n_with_label("User")
.where_(Predicate::eq("metadata.externalID", "crm-42"))
.project(vec![
PropertyProjection::new("userId"),
PropertyProjection::renamed("metadata.externalID", "external_id"),
]),
)
.returning(["users"])
}Dotted property lookup is exact-first and scan-only in V1. Keep indexed/searchable fields top-level; use nested objects for metadata you can scan or project. Arrays are opaque, so there is no metadata.tags.0 syntax.
---
16. Typed-array parameter + DateTime parameter
#[register]
pub fn users_filtered(
statuses: Vec<String>,
since: DateTime,
) -> ReadBatch {
let _ = (&statuses, &since);
read_batch()
.var_as(
"users",
g().n_with_label("User")
.where_(Predicate::and(vec![
Predicate::is_in_param("status", "statuses"),
Predicate::gte_param("createdAt", "since"),
]))
.values(vec!["$id", "status", "createdAt"]),
)
.returning(["users"])
}The macro records statuses as {"Array": "String"} and since as "DateTime". On the client, pass any RFC3339 string or epoch-millis integer; the wrapper normalizes to UTC RFC3339 before serializing.
---
17. Write: index management
#[register]
pub fn bootstrap_indexes() -> WriteBatch {
write_batch()
.var_as(
"idx_userId",
g().create_index_if_not_exists(IndexSpec::node_unique_equality("User", "userId")),
)
.var_as(
"idx_embedding",
g().create_index_if_not_exists(IndexSpec::node_vector(
"Document",
"embedding",
Some("tenantId"),
)),
)
.var_as(
"idx_body",
g().create_index_if_not_exists(IndexSpec::node_text(
"Document",
"body",
Some("tenantId"),
)),
)
.returning(["idx_userId", "idx_embedding", "idx_body"])
}Drop an index with g().drop_index(IndexSpec::...). The convenience methods (create_vector_index_nodes, etc.) are available but produce identical wire output — prefer create_index_if_not_exists + IndexSpec for consistency with the dynamic JSON reference.
---
18. Warm a read route
Warming uses the same query; .warm_only() sets the X-Helix-Warm: true header on the client. Build the request and let callers decide to warm:
use helix_db::Client;
let client = Client::new(Some("https://helix.example.com"))?.with_api_key(Some(&api_key));
// .warm_only() sets X-Helix-Warm: true. A successful warm returns 204 No Content; writes reject warming.
let _: serde_json::Value = client
.query()
.warm_only()
.dynamic(user_by_id("u-42".to_string())?)
.send()
.await?;Warming is strictly read-only; a WriteBatch with X-Helix-Warm: true is rejected by the gateway.
Helix Query Authoring — Rust DSL Reference
Exhaustive builder catalog for the helix-db Rust crate (sdks/rust). Use when SKILL.md points you at a specific category or when you need a signature confirmed. Every entry is grouped by category; categories line up 1:1 with ../helix-query-typescript/REFERENCE.md and ../helix-query-json-dynamic/REFERENCE.md so you can jump between the Rust DSL, TypeScript DSL, and JSON forms.
Import: use helix_db::dsl::prelude::*;. All signatures come from sdks/rust/src/dsl.rs (re-exported at the crate root via pub use dsl::*); line numbers are cited inline.
Typestate Cheat Sheet
Empty -- n,n_where,n_with_label[_where],inject,add_n,create_*_index_*,create_index_if_not_exists,drop_index
└─> OnNodes
Empty -- e,e_where,e_with_label[_where] └─> OnEdges
Empty -- vector_search_nodes[_with], text_search_nodes[_with] └─> OnNodes
Empty -- vector_search_edges[_with], text_search_edges[_with] └─> OnEdges
OnNodes -- out, in_, both, has, has_label, has_key, where_, dedup,
within, without, limit, skip, range, as_, store, select,
inject, order_by[_multiple], repeat, union, choose, coalesce,
optional, path, simple_path, fold, unfold, sack_* ↻ OnNodes
OnNodes -- out_e, in_e, both_e └─> OnEdges
OnNodes -- count, exists, id, label, values, value_map, project,
group, group_count, aggregate_by └─> Terminal
OnNodes(WriteEnabled) -- add_e, set_property, remove_property,
drop, drop_edge, drop_edge_labeled, drop_edge_by_id ↻ OnNodes
OnEdges -- out_n, in_n, other_n └─> OnNodes
OnEdges -- has, has_label, has_key, where_, edge_has, edge_has_label,
dedup, within, without, limit, skip, range, as_, store,
select, order_by[_multiple] ↻ OnEdges
OnEdges -- count, exists, id, label, edge_properties └─> Terminal
OnEdges(WriteEnabled) -- drop_edge_by_id ↻ OnEdgesReadBatch::var_as accepts only Traversal<_, ReadOnly> — mixing a mutation builder into a read batch is a compile error. WriteBatch::var_as accepts either.
---
Batch Entry Points
sdks/rust/src/dsl.rs:4556, :4562, :4133, :2342:
pub fn read_batch() -> ReadBatch
pub fn write_batch() -> WriteBatch
pub fn g() -> Traversal<Empty>
pub fn sub() -> SubTraversalReadBatch / WriteBatch
var_as<S>(name, traversal)— store a named result, unconditional.var_as_if<S>(name, condition: BatchCondition, traversal)— conditional entry.for_each_param(param: &str, body: ReadBatch | WriteBatch)— runbodyonce per object in an array param.body.queriesare inlined inside aBatchEntry::ForEach.returning<I, S: Into<String>>(vars)— restrict the response to these variable names.
BatchCondition (sdks/rust/src/dsl.rs:4142)
BatchCondition::VarNotEmpty(name)
BatchCondition::VarEmpty(name)
BatchCondition::VarMinSize(name, n)
BatchCondition::PrevNotEmpty---
Sources (Traversal<Empty> → Traversal<On*, _>)
sdks/rust/src/dsl.rs:3191 (impl Traversal<Empty, ReadOnly>):
// Nodes
g().n(nodes: impl Into<NodeRef>) -> Traversal<OnNodes>
g().n_where(pred: SourcePredicate) -> Traversal<OnNodes>
g().n_with_label(label) -> Traversal<OnNodes>
g().n_with_label_where(label, pred: SourcePredicate) -> Traversal<OnNodes>
// Edges
g().e(edges: impl Into<EdgeRef>) -> Traversal<OnEdges>
g().e_where(pred: SourcePredicate) -> Traversal<OnEdges>
g().e_with_label(label) -> Traversal<OnEdges>
g().e_with_label_where(label, pred: SourcePredicate) -> Traversal<OnEdges>
// Vector & text search
g().vector_search_nodes(label, property, query_vector: Vec<f32>, k: usize,
tenant_value: Option<PropertyValue>) -> Traversal<OnNodes>
g().vector_search_nodes_with(label, property,
query_vector: impl Into<PropertyInput>,
k: impl Into<StreamBound>,
tenant_value: Option<PropertyInput>) -> Traversal<OnNodes>
g().text_search_nodes(label, property, query_text, k: usize,
tenant_value: Option<PropertyValue>) -> Traversal<OnNodes>
g().text_search_nodes_with(label, property,
query_text: impl Into<PropertyInput>,
k: impl Into<StreamBound>,
tenant_value: Option<PropertyInput>) -> Traversal<OnNodes>
// Edge variants: vector_search_edges[_with], text_search_edges[_with]Prefer the _with variants for parameterized routes — they accept PropertyInput::param("x") and Expr::param("k").
---
Traversal
Node state (sdks/rust/src/dsl.rs:3586, impl<M: MutationMode> Traversal<OnNodes, M>):
traversal.out(label: Option<impl Into<String>>) -> Traversal<OnNodes, M>
traversal.in_(label: Option<impl Into<String>>) -> Traversal<OnNodes, M>
traversal.both(label: Option<impl Into<String>>) -> Traversal<OnNodes, M>
traversal.out_e(label) -> Traversal<OnEdges, M>
traversal.in_e(label) -> Traversal<OnEdges, M>
traversal.both_e(label) -> Traversal<OnEdges, M>Edge state (sdks/rust/src/dsl.rs:4023, impl<M: MutationMode> Traversal<OnEdges, M>):
traversal.out_n() -> Traversal<OnNodes, M> // edge → target
traversal.in_n() -> Traversal<OnNodes, M> // edge → source
traversal.other_n() -> Traversal<OnNodes, M> // edge → "other" endpointPass None::<&str> to skip label filtering: .out(None::<&str>).
---
Filters
.has(prop, value: impl Into<PropertyValue>) // both Nodes & Edges
.has_label(label)
.has_key(prop)
.where_(pred: Predicate)
.dedup()
.within(var_name)
.without(var_name)
.edge_has(prop, value: impl Into<PropertyInput>) // Edges only
.edge_has_label(label) // Edges onlyOn edge streams, generic .has, .has_label, .has_key, and .where_ filter stored edge properties plus virtual fields $id, $label, $from, $to, $distance, and $score. Keep .edge_has for edge filters whose right-hand side must be a PropertyInput expression or runtime parameter.
Predicate (enum sdks/rust/src/dsl.rs:1564, impl :1811)
Literal constructors:
Predicate::eq(prop, val) Predicate::neq(prop, val)
Predicate::gt(prop, val) Predicate::gte(prop, val)
Predicate::lt(prop, val) Predicate::lte(prop, val)
Predicate::between(prop, min, max)
Predicate::has_key(prop) Predicate::is_null(prop)
Predicate::is_not_null(prop)
Predicate::starts_with(prop, s) Predicate::ends_with(prop, s)
Predicate::contains(prop, s) Predicate::contains_param(prop, param)
Predicate::is_in(prop, vals: impl Into<PropertyValue>)
Predicate::is_in_expr(prop, expr) Predicate::is_in_param(prop, param)
Predicate::and(preds) Predicate::or(preds)
Predicate::not(pred)
Predicate::compare(left: Expr, op: CompareOp, right: Expr)Parameterized comparison shortcuts (wrap Compare):
Predicate::eq_param(prop, param) Predicate::neq_param(prop, param)
Predicate::gt_param(prop, param) Predicate::gte_param(prop, param)
Predicate::lt_param(prop, param) Predicate::lte_param(prop, param)SourcePredicate (enum sdks/rust/src/dsl.rs:1619, impl :1658)
Restricted subset for n_where / e_where (must be index-friendly):
SourcePredicate::eq / neq / gt / gte / lt / lte / between / has_key / starts_with / and / orEach comparison auto-routes by argument type. A literal keeps the plain variant (SourcePredicate::eq("status", "active") → Eq("status", String("active"))); an Expr/param routes to the *Expr variant (SourcePredicate::eq("status", Expr::param("s")) → EqExpr("status", Param("s"))). The enum carries both forms (Eq/EqExpr, Between/BetweenExpr, etc.); .to_predicate() maps the *Expr variants to Compare.
Not available at source position: is_null, is_not_null, contains[_param], ends_with, is_in*, not, compare. Push those into a following .where_(Predicate::...).
Property-name strings in filters can be dotted object paths, for example Predicate::eq("metadata.externalID", "crm-42"). Lookup is exact-first: a top-level property named metadata.externalID wins before walking the metadata object. Dotted paths are scan-only in V1; secondary, text, and vector indexes remain top-level only. Arrays are opaque and do not support tags.0 syntax.
CompareOp
CompareOp::{Eq, Neq, Gt, Gte, Lt, Lte}---
Expressions
Expr (enum sdks/rust/src/dsl.rs:1368, impl :1402):
Expr::prop(name) Expr::val(value: impl Into<PropertyValue>)
Expr::id() Expr::param(name)
Expr::timestamp() // server UTC epoch millis (i64)
Expr::datetime() // server typed DateTime
expr.add(other) expr.sub(other) expr.mul(other) expr.div(other)
expr.modulo(other) expr.neg()
Expr::case(when_then: Vec<(Predicate, Expr)>, else_expr: Option<Expr>)Typical uses:
Predicate::compare(Expr::prop("age"), CompareOp::Gte, Expr::param("minAge"))— property-to-parameter comparison with typed coercion.Expr::prop("metadata.score")— nested object field lookup with the same exact-first dotted-path rules as filters.ExprProjection::new("age_plus_one", Expr::prop("age").add(Expr::val(1i64)))— computed column.PropertyInput::from(Expr::timestamp())insideadd_n("Foo", vec![("createdAt", Expr::timestamp())])— server-side timestamp stamp.
---
Stream Bounds & Limits
.limit(n: impl Into<StreamBound>)
.skip(n: impl Into<StreamBound>)
.range(start: impl Into<StreamBound>, end: impl Into<StreamBound>)StreamBound accepts usize, u8/u16/u32, i64/i32 (errors into Expr::Constant when negative), and Expr. Canonical forms:
.limit(25usize) // StreamBound::Literal
.limit(Expr::param("limit")) // StreamBound::Expr---
Variables & Injection
.as_(name) // store current stream
.store(name) // alias of .as_
.select(name) // replace current stream with a stored var
.inject(name) // inject a var into the stream (source or mid-traversal)
g().inject(name) // Empty -> OnNodes source formCross-entry references use NodeRef::var(name), EdgeRef::var(name), NodeRef::param(name), EdgeRef::param(name).
---
Ordering
.order_by(property, order: Order) // Order::{Asc, Desc}
.order_by_multiple(vec![(prop1, Order::Desc), (prop2, Order::Asc)])Dotted paths such as metadata.score are valid for fallback ordering, but V1 range indexes cannot accelerate nested paths.
---
Aggregation (terminals)
.count() -> Traversal<Terminal, M>
.exists() -> Traversal<Terminal, M>
.group(property) -> Traversal<Terminal, M>
.group_count(property) -> Traversal<Terminal, M>
.aggregate_by(fn: AggregateFunction, property) -> Traversal<Terminal, M>
// AggregateFunction::{Count, Sum, Min, Max, Mean}---
Branching
Each arm is a SubTraversal, built by sub() + the same filter / traversal / projection methods:
.union(vec![sub_a, sub_b, ...])
.choose(condition: Predicate, then_t: SubTraversal, else_t: Option<SubTraversal>)
.coalesce(vec![sub_a, sub_b, ...]) // first non-empty wins
.optional(sub_a) // pass through if sub_a is emptySubTraversal API (struct sdks/rust/src/dsl.rs:2124, impl :2129) includes: out, in_, both, out_e, in_e, both_e, out_n, in_n, other_n, has, has_label, has_key, where_, dedup, within, without, edge_has, edge_has_label, limit, skip, range, as_, store, select, order_by, order_by_multiple, path, simple_path.
---
Repeat
traversal.repeat(RepeatConfig::new(sub()).times(3))
traversal.repeat(
RepeatConfig::new(sub().out(Some("KNOWS")))
.until(Predicate::eq("title", "CEO"))
.emit_after()
.max_depth(10)
)RepeatConfig (struct sdks/rust/src/dsl.rs:2350, impl :2365):
.times(n: usize)— fixed iterations.until(Predicate)— stop when predicate is true.emit_all(),.emit_before(),.emit_after()— emit policy.emit_if(Predicate)— emit only matching elements after each iteration (sets emit toAfter).max_depth(n)— safety cap (default 100)
Default emit is EmitBehavior::None (only the final result is returned). Bound every repeat with times or until; don't rely on max_depth alone.
---
Projections (terminals)
.values(vec!["name", "email"]) -> Traversal<Terminal, M>
.value_map(Some(vec!["$id", "name"])) -> Traversal<Terminal, M>
.value_map(None::<Vec<&str>>) -> Traversal<Terminal, M> // all properties
.project(vec![...]: Vec<impl Into<Projection>>) -> Traversal<Terminal, M>
.edge_properties() -> Traversal<Terminal, M> // OnEdges onlyProjection constructors (sdks/rust/src/dsl.rs:1988-2062):
PropertyProjection::new("name") // no rename; source == alias
PropertyProjection::renamed("$distance", "distance")
ExprProjection::new("age_plus_one", Expr::prop("age").add(Expr::val(1i64)))
Projection::property("source", "alias")
Projection::expr("alias", expr)
Projection::from_endpoint("resource_id", "from_id")
Projection::to_endpoint("resource_id", "to_id")PropertyProjection and ExprProjection both implement Into<Projection>, so you can mix them freely in .project(vec![...]). Filtered values(...), filtered value_map(...), PropertyProjection::source, and Expr::prop(...) accept dotted object paths. value_map(None) returns all top-level stored properties as-is and does not flatten nested objects.
On edge streams, Projection::from_endpoint(prop, alias) serializes to {"source":"$from.<prop>","alias":"<alias>"} and Projection::to_endpoint(prop, alias) serializes to {"source":"$to.<prop>","alias":"<alias>"}. Use these to return source/target node properties such as resource ids without traversing from every edge to its endpoints. Keep .edge_properties() for full edge maps and the internal $from / $to node ids.
---
Terminals (metadata)
.count() .exists() .id() .label()Usable on both node and edge streams. .edge_properties() is edge-only.
---
Mutations (write-only) (sdks/rust/src/dsl.rs:3191, :3586)
Source-position mutation (Traversal<Empty> → Traversal<OnNodes, WriteEnabled>):
g().add_n(label, vec![(prop, PropertyInput::from(val)), ...])
g().drop_edge_by_id(edges: impl Into<EdgeRef>)
g().inject(var_name) // from var (ReadOnly side), safe to use in write batchesNode-state mutations (Traversal<OnNodes, _> → Traversal<OnNodes, WriteEnabled>):
.add_e(label, to: impl Into<NodeRef>, vec![(prop, PropertyInput::from(val)), ...])
.set_property(name, value: impl Into<PropertyInput>)
.remove_property(name)
.drop()
.drop_edge(to: impl Into<NodeRef>)
.drop_edge_labeled(to: impl Into<NodeRef>, label)
.drop_edge_by_id(edges: impl Into<EdgeRef>)Edge-state mutation:
.drop_edge_by_id(edges: impl Into<EdgeRef>) // OnEdges -> OnEdges, WriteEnabledKey PropertyInput shortcuts:
PropertyInput::from("literal") // wraps as Value(PropertyValue)
PropertyInput::from(Expr::timestamp()) // wraps Expr
PropertyInput::param("userId") // wraps Expr::Param("userId")
PropertyInput::from(PropertyValue::object(vec![("externalID", PropertyValue::from("crm-42"))]))---
Indexes (write-only) (sdks/rust/src/dsl.rs:3191)
Generic IndexSpec forms:
g().create_index_if_not_exists(spec: IndexSpec) -> Traversal<Terminal, WriteEnabled>
g().drop_index(spec: IndexSpec) -> Traversal<Terminal, WriteEnabled>Convenience source forms:
g().create_vector_index_nodes(label, property, tenant_property: Option<impl Into<String>>)
g().create_vector_index_edges(label, property, tenant_property)
g().create_text_index_nodes(label, property, tenant_property)
g().create_text_index_edges(label, property, tenant_property)IndexSpec constructors (enum sdks/rust/src/dsl.rs:2427, impl :2501):
IndexSpec::node_equality(label, property) // unique = false
IndexSpec::node_unique_equality(label, property) // unique = true
IndexSpec::node_range(label, property)
IndexSpec::node_range_desc(label, property)
IndexSpec::node_range_with_direction(label, property, RangeIndexDirection::Desc)
IndexSpec::edge_equality(label, property)
IndexSpec::edge_range(label, property)
IndexSpec::edge_range_desc(label, property)
IndexSpec::edge_range_with_direction(label, property, RangeIndexDirection::Desc)
IndexSpec::node_vector(label, property, tenant_property: Option<impl Into<String>>)
IndexSpec::node_text(label, property, tenant_property)
IndexSpec::edge_vector(label, property, tenant_property)
IndexSpec::edge_text(label, property, tenant_property)Range indexes default to ascending physical order. Use RangeIndexDirection::Desc for descending indexes that primarily serve newest-first or high-score-first scans.
Index properties are top-level only in V1. Do not declare metadata.externalID as an equality, range, vector, or text index; duplicate indexed/searchable fields onto explicit top-level properties.
---
Reserved / no-op builders
Emit the corresponding steps but have no effect in the current interpreter. Safe to include for forward-compatible queries.
.fold() .unfold() .path() .simple_path()
.with_sack(PropertyValue::I64(0))
.sack_set(prop) .sack_add(prop) .sack_get()---
#[register] Macro & Dynamic Transport
sdks/rust/helix-dsl-macros/src/lib.rs. Apply to a top-level function returning ReadBatch or WriteBatch; the macro generates a wrapper that constructs a DynamicQueryRequest with the function's arguments as typed parameters and sets top-level query_name to the Rust function name.
#[register]
pub fn find_user(tenant_id: String, limit: i64) -> ReadBatch {
read_batch()
.var_as(
"users",
g().n_with_label("User")
.where_(Predicate::eq_param("tenantId", "tenant_id"))
.limit(limit)
.value_map(Some(vec!["$id", "name"])),
)
.returning(["users"])
}
// Generated: callable fn that returns DynamicQueryRequest
let req = find_user("acme".to_string(), 25)?; // Result<DynamicQueryRequest, DynamicQueryError>
let json = req.to_json_string()?;The serialized request from the registered helper includes "query_name":"find_user", so gateway logs and slow-query diagnostics can group this inline request by name.
Supported param types: primitives (bool, i64, f64, f32, String, DateTime), PropertyValue, ParamValue, ParamObject, Vec<T> (any supported T), BTreeMap<String, T>, HashMap<String, T>, Vec<u8> (bytes — not supported over the dynamic JSON route, raises DynamicQueryError::UnsupportedBytesParameter).
Query bundles
sdks/rust/src/query_generator.rs:
pub fn build_query_bundle() -> Result<QueryBundle, GenerateError>
pub fn serialize_query_bundle(bundle: &QueryBundle) -> Result<Vec<u8>, GenerateError>
pub fn deserialize_query_bundle(bytes: &[u8]) -> Result<QueryBundle, GenerateError>
pub fn write_query_bundle_to_path<P: AsRef<Path>>(bundle: &QueryBundle, path: P) -> Result<(), GenerateError>
pub fn read_query_bundle_from_path<P: AsRef<Path>>(path: P) -> Result<QueryBundle, GenerateError>
pub fn generate() -> Result<PathBuf, GenerateError> // writes queries.json in CWD
pub fn generate_to_path<P: AsRef<Path>>(path: P) -> Result<PathBuf, GenerateError>Wire format version: QUERY_BUNDLE_VERSION = 4. deserialize_query_bundle rejects mismatched versions.
DynamicQueryRequest
DynamicQueryRequest::read(batch: ReadBatch)
DynamicQueryRequest::write(batch: WriteBatch)
req.set_query_name("find_users")
req.clear_query_name()
req.with_query_name("find_users")
req.with_parameter_value(name, DynamicQueryValue::String("x".into()))
req.with_parameter_type(name, QueryParamType::DateTime)
req.to_json_string() // Result<String, DynamicQueryError>
req.to_json_bytes()Direct requests built with DynamicQueryRequest::read/write serialize query_name: null until a name is set. Missing or null falls back to __dynamic__ at the gateway; blank names are rejected.
For the JSON wire encoding this produces, see ../helix-query-json-dynamic/REFERENCE.md.
Client (sending requests)
Async HTTP client for running a request against a Helix instance (reqwest-based).
use helix_db::{Client, HelixError};
Client::new(url: Option<&str>) -> Result<Self, HelixError> // default "http://localhost:6969"; InvalidURL on bad url
.with_api_key(api_key: Option<&str>) -> Self // Authorization: Bearer <key>
.query::<R: Deserialize>() -> QueryBuilder<R>
// QueryBuilder — request headers + body, then pick a route:
.writer_only() // X-Helix-Require-Writer: true
.warm_only() // X-Helix-Warm: true
.should_await_durability(b: bool) // X-Helix-Await-Durable: true|false
.body(&data)? -> Self // JSON body for a stored route
.dynamic(req: DynamicQueryRequest) -> QueryRequest<R> // POST /v1/query
.stored(name: String) -> QueryRequest<R> // POST /v1/query/{name}
request.send().await -> Result<R, HelixError> // 200 -> R; any other status -> HelixError::RemoteErrorPrefer .should_await_durability(true) on writes. Under concurrent writers, not awaiting durability raises the chance of HTTP 409 write conflicts; awaiting it reduces them (but does not eliminate them, so callers still own retry). Leaving it off is fine for low-concurrency or read paths.
HelixError variants: ReqwestError (transport), RemoteError { details } (non-200), SerializationError, InvalidURL. Build the DynamicQueryRequest from a registered fn call (count_users()) or DynamicQueryRequest::read(batch).
---
Common Pitfalls
#[expect(dead_code)]on a helper fails the test build if tests use it — use#[allow(dead_code)].ReadBatch::var_asrejects a traversal containing anyWriteEnabledstep at compile time — if a builder call returnsTraversal<_, WriteEnabled>, the enclosing batch must be aWriteBatch..out(None)doesn't compile (ambiguous type). Use.out(None::<&str>)or passSome("LABEL").PropertyInput::param("x")is the idiomatic way to tie a property write to a parameter; do not constructExpr::Paramand then wrap manually unless you need composition.n_where(SourcePredicate::contains(...))is a compile error —SourcePredicatedoes not havecontains. Move the predicate into.where_(Predicate::contains(...))after the source.vector_search_*(non-_with) takes a concreteVec<f32>+usize; parameterized routes needvector_search_*_withto acceptPropertyInput::param/Expr::param.