
Helix Query Typescript
- 136 installs
- Updated August 3, 2026
- helixdb/skills
Writes and reviews HelixDB queries in the TypeScript DSL with traversals, projections, indexes, and vector or BM25 search.
About
Writes and revises HelixDB queries with the TypeScript DSL (@helix-db/helix-db) using readBatch/writeBatch, traversal builders, projections, and BM25/vector search. A developer uses it to author or review Helix queries in a TypeScript codebase.
- Type-checked @helix-db/helix-db DSL (readBatch/writeBatch/g())
- Emits the same JSON AST as the Rust DSL; supports query bundles
Helix Query Typescript by the numbers
- 136 all-time installs (skills.sh)
- +12 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #288 of 911 Databases 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-typescriptAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 136 |
|---|---|
| Last updated | August 3, 2026 |
| Repository | helixdb/skills ↗ |
What it does
Writes and reviews HelixDB queries in the TypeScript DSL with traversals, projections, indexes, and vector or BM25 search.
Files
Helix Query Authoring — TypeScript
Write Helix TypeScript DSL queries in a way that is schema-aware, explicit, and easy for agents to reason about. The TypeScript builder (@helix-db/helix-db) produces the same JSON AST as the Rust DSL; the compatibility target is structural JSON equality with Rust serde output.
This is the preferred way to author Helix queries in a TypeScript codebase — type-checked, and it emits the dynamic-request JSON for you. 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 TypeScript
- revise an existing TypeScript query function
- produce a dynamic
POST /v1/queryrequest from TypeScript (toDynamicJson/toDynamicRequest) - send a request to a running Helix instance with the built-in
Client(client.query().dynamic(req).send()) - generate a query bundle (
defineQueries(...).generate("queries.json")) - add traversal, projection, pagination, BM25 search, or vector search to an existing query
- migrate a Rust DSL query (
#[register],read_batch(), …) to TypeScript
Do not use this skill for inline JSON AST hand-authoring — for the wire format and serde rules that govern what these builders emit, use helix-query-json-dynamic. For the Rust DSL, use helix-query-rust.
First Steps
Before writing any query code:
1. Inspect the local repo for existing labels, edge labels, properties, and route patterns. Reuse exact casing (tenantId, FOLLOWS, RelatesTo) — do not normalize names. 2. Find the closest existing query and reuse its naming, projection, and scoping style. 3. Decide whether the route is a read (readBatch()) or a write (writeBatch()). 4. Identify the narrowest indexed anchor before planning the traversal.
If the local repo is thin on examples, use the companion files:
1. EXAMPLES.md — working end-to-end TypeScript queries (reads, writes, search, repeat, branching, upsert, forEachParam, index management). Scenarios are numbered to match ../helix-query-rust/EXAMPLES.md and ../helix-query-json-dynamic/EXAMPLES.md 1:1. 2. REFERENCE.md — full builder catalog organized by category, with typestate notes and src/index.ts line citations.
Open REFERENCE.md whenever you need a builder beyond the common surface (addE, dropEdgeById, createVectorIndexNodes, repeat, choose, coalesce, optional, aggregateBy, groupCount, inject, orderByMultiple, Expr.case, the *With search variants, etc.) — do not invent method names from memory.
Core Authoring Rules
1. Start With The Right Batch Type
readBatch()for read-only routeswriteBatch()for any mutation (adds a node/edge, updates/removes a property, drops data, or creates/drops an index)
ReadBatch.varAs accepts only read-only traversals and throws TypeError at runtime if handed a write traversal (the type system also rejects it at compile time). WriteBatch.varAs accepts either.
2. Compose With varAs / returning
A batch is a list of named query entries plus a returns list:
readBatch()
.varAs("user", g().nWhere(SourcePredicate.eq("username", "alice")))
.varAs("friends", g().n(NodeRef.var("user")).out("FOLLOWS").dedup().limit(100))
.returning(["user", "friends"]);.varAs(name, traversal)— store a named result..varAsIf(name, condition, traversal)— conditional entry (BatchCondition.varNotEmpty(name),varEmpty,varMinSize,prevNotEmpty)..forEachParam(paramName, body)— runbody(a batch) once per object in an array parameter..returning([...])— restrict the response to these variable names.
Cross-entry references use NodeRef.var(name) / EdgeRef.var(name); parameters use NodeRef.param(name) / EdgeRef.param(name).
3. Anchor Narrow, Then Traverse
Prefer this anchor order: node/edge ID → unique property lookup → equality-indexed property lookup → scoped label scan → broad label scan (last resort). nWithLabel("User") desugars to nWhere(SourcePredicate.eq("$label", "User")); nWithLabelWhere("User", pred) builds the scoped and. Do not start from a broad label scan when an indexed identifier exists.
4. Keep Output Shape Intentional
.project([...])for stable service-facing response shapes (mixPropertyProjectionandExprProjection)..valueMap(["$id", "name"])(or.valueMap(null)for all) when returning many properties is acceptable..edgeProperties()for edge streams.- For edge endpoint properties, prefer edge-stream
.project([...])with
Projection.fromEndpoint(prop, alias) / Projection.toEndpoint(prop, alias) instead of traversing to every endpoint first.
Do not return oversized properties like embeddings unless the caller explicitly needs them.
5. Preserve Search Scope
For BM25 and vector search: keep the chosen text/vector property explicit, pass the tenant value when the index is scoped, and project $distance before traversing off the hit stream (out/in/both drop the distance metadata). Prefer the *With variants for parameterized routes — they accept PropertyInput.param(...), Expr.param(...), and StreamBound.
6. Use Traversal Controls Deliberately
Apply dedup, limit, range, skip, count because the route needs them, not by habit. Bound every repeat(...) with times or until; the default maxDepth is 100.
7. Prefer Explicit Write Branching Over Invented MERGE Semantics
For create-or-update: load existing nodes, branch with varAsIf (VarNotEmpty → update, VarEmpty → create). See EXAMPLES.md §13.
8. Parameters: defineParams + Plain Builder Functions
Query builders are plain functions that return a ReadBatch/WriteBatch. Define parameter schemas once and reference them:
const params = defineParams({ tenantId: param.string(), limit: param.i64() });
function findUsers(p = params) {
return readBatch()
.varAs("users", g().nWithLabel("User").where(Predicate.eqParam("tenantId", "tenantId")).limit(p.limit).valueMap(["$id", "name"]))
.returning(["users"]);
}- A
ParamRef(e.g.p.limit) can be passed directly to.limit(...), searchk, etc. - Predicate
*Paramhelpers (Predicate.eqParam(prop, paramName)) andPropertyInput.param(paramName)reference parameters by name string. - Supported schemas:
param.bool/i64/f64/f32/string/dateTime/bytes/value/object/object(inner)/array(inner).
9. Choose The Output Path
- Dynamic request:
findUsers().toDynamicJson(params, { tenantId: "acme", limit: 25n }, { queryName: "find_users" })→ request JSON string forPOST /v1/query. UsetoDynamicRequest(...)for the object,toDynamicBytes(...)for bytes. No-parameter queries take no schema argument:countUsers().toDynamicJson({ queryName: "count_users" }). Unnamed requests serializequery_name: null, which the gateway records as__dynamic__. - Raw batch JSON:
findUsers().toJsonString()— the inlinequerybody only (no envelope). - Bundle: register queries and generate a
queries.json(see Rule 10). - Send it with the client:
new Client(url).withApiKey(key).query<R>().dynamic(findUsers().toDynamicRequest(params, values, { queryName: "find_users" })).send()POSTs to/v1/queryand returns the parsed JSON on HTTP 200, else throwsHelixError. Add.warmOnly()/.writerOnly()/.shouldAwaitDurability(b)for the matching request headers; use.stored(name).body(x)for a deployed named route. Prefer.shouldAwaitDurability(true)on writes — under concurrent writers it reduces HTTP 409 write conflicts (callers still own retry). See REFERENCE.md → "Client".
10. Bundles: registerRead / registerWrite / defineQueries
Registration is needed when bundling queries into a queries.json:
export const queries = defineQueries({
read: { find_users: registerRead(findUsers, params) },
write: { add_user: registerWrite(addUser, addUserParams) },
});
queries.call.find_users({ tenantId: "acme", limit: 25n }); // -> DynamicQueryRequest with query_name="find_users"
await queries.generate("queries.json"); // bundle, version 4Route names must be unique across read and write routes — duplicates throw GenerateError.
Number & DateTime Handling
- Use
bigint(25n) ori64(...)for fulli64range; plainnumberis accepted only for safe integers when an integer is required. - Serialize bigint-bearing payloads with
toJsonString()/stringifyJson()/serializeQueryBundle(), never rawJSON.stringify. DateTimestores epoch milliseconds (negative allowed):DateTime.fromMillis(ms),DateTime.parseRfc3339(s),.toRfc3339(). Declare the parameter asparam.dateTime(); dynamic request values render as UTC RFC3339 with millisecond precision.- Nested object/array property values are supported through normal object and array inputs or
PropertyValue.object/array. Read nested object fields with dotted property strings such asmetadata.externalID; lookup is exact-first and scan-only in V1.
Builder Surface At A Glance
| Category | Primary builders | Notes |
|---|---|---|
| Entry points | g(), sub(), readBatch(), writeBatch() | g() starts a Traversal<"empty","read">. |
| Sources | n, nWhere, nWithLabel, nWithLabelWhere, e, eWhere, eWithLabel, eWithLabelWhere, vectorSearchNodes[With], textSearchNodes[With], vectorSearchEdges[With], textSearchEdges[With] | Anchor narrowly. *With variants accept params/exprs. |
| Traversal | out, in, both, outE, inE, bothE, outN, inN, otherN | Label arg is optional (out("FOLLOWS") or out()). *E switch to the edge stream. |
| Filters | has, hasLabel, hasKey, where, dedup, within, without, edgeHas, edgeHasLabel | Predicate.* + Predicate.*Param; dotted paths like metadata.externalID are scan-only. |
| Limits | limit, skip, range | Accept number, bigint, Expr, ParamRef, or StreamBound. |
| Variables | as, store, select, inject | Cross-entry refs via NodeRef.var/param, EdgeRef.var/param. |
| Ordering | orderBy, orderByMultiple | Order.Asc / Order.Desc. |
| Aggregation | count, exists, group, groupCount, aggregateBy | 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).emitAfter().maxDepth(100)) | Bound with times/until; default maxDepth 100. |
| Projection | values, valueMap, project, edgeProperties | project mixes PropertyProjection (incl. renamed) and ExprProjection; filtered outputs accept dotted paths; edge streams can project endpoint fields with Projection.fromEndpoint / Projection.toEndpoint. |
| Expressions | Expr.prop/val/id/timestamp/datetime/param, .add/.sub/.mul/.div/.modulo/.neg, Expr.case | Expr.timestamp() writes server UTC millis; Expr.datetime() writes typed datetime. |
| Mutations | addN, addE, setProperty, removeProperty, drop, dropEdge, dropEdgeLabeled, dropEdgeById | dropEdgeById is multigraph-safe. |
| Indexes | createIndexIfNotExists(spec), dropIndex(spec), plus createVectorIndexNodes/Edges, createTextIndexNodes/Edges; IndexSpec.nodeEquality/nodeUniqueEquality/nodeRange/nodeRangeDesc/nodeRangeWithDirection/edgeEquality/edgeRange/edgeRangeDesc/edgeRangeWithDirection/nodeVector/nodeText/edgeVector/edgeText | All write-only and top-level only for indexed properties. RangeIndexDirection.Desc sets descending physical order. |
| Output | toJsonString, toDynamicJson, toDynamicRequest, toDynamicBytes | Dynamic forms take (params, values, options) unless the query has no parameters; pass { queryName } to set top-level query_name. |
| Client / transport | new Client(url), .withApiKey, .query<R>(), .writerOnly/.warmOnly/.shouldAwaitDurability, .body, .dynamic/.stored, .send() | Sends to POST /v1/query; send() resolves parsed JSON on 200, else throws HelixError. |
| Bundles | defineParams, param.*, registerRead, registerWrite, defineQueries, serializeQueryBundle, .buildQueryBundle(), .generate() | QUERY_BUNDLE_VERSION = 4. |
See REFERENCE.md for full signatures and typestate constraints.
Canonical Examples
Read By Indexed Identifier
const params = defineParams({ userId: param.string() });
function userById(p = params) {
return readBatch()
.varAs(
"user",
g()
.nWithLabel("User")
.where(Predicate.eqParam("userId", "userId"))
.project([
PropertyProjection.renamed("$id", "id"),
PropertyProjection.new("userId"),
PropertyProjection.new("name"),
]),
)
.returning(["user"]);
}
const body = userById().toDynamicJson(params, { userId: "u-42" });Explicit Create Or Update
const upsertParams = defineParams({ userId: param.string(), name: param.string() });
function upsertUser(p = upsertParams) {
return writeBatch()
.varAs("existing", g().nWithLabel("User").where(Predicate.eqParam("userId", "userId")))
.varAsIf(
"updated",
BatchCondition.varNotEmpty("existing"),
g().n(NodeRef.var("existing")).setProperty("name", PropertyInput.param("name")),
)
.varAsIf(
"created",
BatchCondition.varEmpty("existing"),
g().addN("User", { userId: PropertyInput.param("userId"), name: PropertyInput.param("name") }),
)
.returning(["updated", "created"]);
}Scoped Search Route
const searchParams = defineParams({ tenantId: param.string(), queryVector: param.array(param.f64()), limit: param.i64() });
function nearestDocuments(p = searchParams) {
return readBatch()
.varAs(
"results",
g()
.vectorSearchNodesWith("Document", "embedding", PropertyInput.param("queryVector"), Expr.param("limit"), PropertyInput.param("tenantId"))
.project([
PropertyProjection.renamed("$id", "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, or ignore tenant scope on text/vector search
- add
deduporlimitwithout a reason - call
JSON.stringifyon a payload that may containbigint— usetoJsonString/stringifyJson - pass a
param.bytes()parameter through the dynamic route — it throwsDynamicQueryError(UnsupportedBytesParameter) - reuse a route name across read and write routes —
defineQueriesthrowsGenerateError - put a write traversal into
readBatch().varAs(...)— it is rejected at compile time and throws at runtime - traverse off a vector/text hit stream before projecting
$distance
Validation Checklist
Before finishing:
- verify
readBatch()versuswriteBatch()is correct - verify labels, edge labels, and properties match the repo exactly
- verify the first anchor is the narrowest practical indexed set
- verify the returned variable names and shape match service expectations
- verify text/vector routes pass the tenant value when the index is scoped, and project
$distancebefore navigating - verify
bigint/i64(...)is used for large integers and serialization goes throughtoJsonString/stringifyJson - verify
DateTimeparameters useparam.dateTime()andDateTime.*values - verify route names are unique if registering a bundle
- verify the query matches surrounding local style more than any generic example
Reference Files
REFERENCE.md— full builder catalog (entry points, scalars, refs, expressions, predicates, projections, branching, repeat, mutations, indexes, batches, parameters, registration/bundles, dynamic requests), withsrc/index.tscitations and a Rust↔TS naming map.EXAMPLES.md— end-to-end TypeScript queries mirroring the scenarios in../helix-query-rust/EXAMPLES.mdand../helix-query-json-dynamic/EXAMPLES.md, so you can move fluently between the Rust DSL, TypeScript DSL, and JSON forms.
Helix Query Authoring — TypeScript Examples
Each numbered scenario corresponds 1:1 with ../helix-query-rust/EXAMPLES.md and ../helix-query-json-dynamic/EXAMPLES.md. When moving between TypeScript, Rust, and inline JSON, open the same scenario in each file.
All snippets assume import { ... } from "@helix-db/helix-db";. Query builders are plain functions returning a ReadBatch/WriteBatch. Produce a dynamic request with builder().toDynamicJson(params, values, { queryName: "route_name" }) (or .toDynamicJson({ queryName: "route_name" }) when there are no parameters), or register the builder in defineQueries({...}) for a query bundle. To run a request against a Helix instance, hand builder().toDynamicRequest(params, values, { queryName: "route_name" }) to the built-in Client: await new Client(url).withApiKey(key).query<R>().dynamic(req).send() (see REFERENCE.md → "Client"). Unnamed direct requests serialize query_name: null; queries.call.* sets query_name to the registered route key automatically.
---
1. Count nodes matching label + predicate
function activeUserCount() {
return readBatch()
.varAs("active_count", g().nWithLabel("User").where(Predicate.eq("status", "active")).count())
.returning(["active_count"]);
}
const body = activeUserCount().toDynamicJson(); // no parameters---
2. Read node by indexed property with projection
Literal form:
function userByIdLiteral() {
return readBatch()
.varAs(
"user",
g()
.nWithLabelWhere("User", SourcePredicate.eq("userId", "u-42"))
.project([
PropertyProjection.renamed("$id", "id"),
PropertyProjection.new("userId"),
PropertyProjection.new("name"),
]),
)
.returning(["user"]);
}Parameterized form (preferred):
const userByIdParams = defineParams({ userId: param.string() });
function userById(p = userByIdParams) {
return readBatch()
.varAs(
"user",
g()
.nWithLabel("User")
.where(Predicate.eqParam("userId", "userId"))
.project([PropertyProjection.renamed("$id", "id"), PropertyProjection.new("name")]),
)
.returning(["user"]);
}
const body = userById().toDynamicJson(userByIdParams, { userId: "u-42" });---
3. Multi-hop traversal with dedup + limit
const fofParams = defineParams({ userId: param.array(param.i64()) });
function friendsOfFriends(p = fofParams) {
return readBatch()
.varAs(
"fof",
g()
.n(NodeRef.param("userId"))
.out("FOLLOWS")
.out("FOLLOWS")
.dedup()
.limit(50)
.values(["$id", "name"]),
)
.returning(["fof"]);
}
const body = friendsOfFriends().toDynamicJson(fofParams, { userId: [1n, 2n] });---
4. Vector search with tenant + distance in projection
const nearestParams = defineParams({
tenantId: param.string(),
queryVector: param.array(param.f64()),
k: param.i64(),
});
function nearestDocuments(p = nearestParams) {
return readBatch()
.varAs(
"hits",
g()
.vectorSearchNodesWith(
"Document",
"embedding",
PropertyInput.param("queryVector"),
Expr.param("k"),
PropertyInput.param("tenantId"),
)
.project([
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
const docSearchParams = defineParams({ tenantId: param.string(), q: param.string() });
function documentSearch(p = docSearchParams) {
return readBatch()
.varAs(
"results",
g()
.textSearchNodesWith("Document", "body", PropertyInput.param("q"), 50, PropertyInput.param("tenantId"))
.where(Predicate.eq("published", true))
.limit(10)
.project([
PropertyProjection.renamed("$id", "id"),
PropertyProjection.new("title"),
PropertyProjection.renamed("$distance", "score"),
]),
)
.returning(["results"]);
}---
6. repeat traversal with until + emitAfter
const chainParams = defineParams({ startId: param.array(param.i64()) });
function managementChain(p = chainParams) {
return readBatch()
.varAs(
"chain",
g()
.n(NodeRef.param("startId"))
.repeat(
RepeatConfig.new(sub().out("REPORTS_TO"))
.until(Predicate.eq("title", "CEO"))
.emitAfter()
.maxDepth(10),
)
.project([
PropertyProjection.renamed("$id", "id"),
PropertyProjection.new("name"),
PropertyProjection.new("title"),
]),
)
.returning(["chain"]);
}---
7. union of two sub-traversals
const networkParams = defineParams({ userId: param.array(param.i64()) });
function userNetwork(p = networkParams) {
return readBatch()
.varAs(
"network",
g()
.n(NodeRef.param("userId"))
.union([sub().out("FOLLOWS"), sub().in("FOLLOWS")])
.dedup()
.values(["$id", "name"]),
)
.returning(["network"]);
}---
8. choose (conditional traversal)
const contentParams = defineParams({ userId: param.array(param.i64()) });
function userContent(p = contentParams) {
return readBatch()
.varAs(
"content",
g()
.n(NodeRef.param("userId"))
.choose(Predicate.eq("tier", "premium"), sub().out("HAS_PREMIUM"), sub().out("HAS_FREE"))
.limit(20)
.valueMap(["$id", "title"]),
)
.returning(["content"]);
}---
9. coalesce (fallback traversal)
const teamParams = defineParams({ userId: param.array(param.i64()) });
function preferredTeam(p = teamParams) {
return readBatch()
.varAs(
"team",
g()
.n(NodeRef.param("userId"))
.coalesce([sub().out("PREFERRED_TEAM"), sub().out("PRIMARY_TEAM"), sub().out("MEMBER_OF").limit(1)])
.values(["$id", "name"]),
)
.returning(["team"]);
}---
10. project with Expr.case (computed field)
function usersWithBucket() {
return readBatch()
.varAs(
"users",
g()
.nWithLabel("User")
.project([
Projection.property("$id", "id"),
Projection.property("score", "score"),
Projection.expr(
"bucket",
Expr.case(
[
[Predicate.gte("score", 1000), Expr.val("high")],
[Predicate.gte("score", 100), Expr.val("mid")],
],
Expr.val("low"),
),
),
]),
)
.returning(["users"]);
}---
11. Aggregation: groupCount and aggregateBy
function usersByStatus() {
return readBatch()
.varAs("by_status", g().nWithLabel("User").groupCount("status"))
.returning(["by_status"]);
}
function totalRevenue() {
return readBatch()
.varAs("revenue", g().nWithLabel("Order").aggregateBy(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.
function listDescribesRelationships() {
return readBatch()
.varAs(
"relationships",
g()
.eWithLabel("DESCRIBES")
.project([
Projection.fromEndpoint("resource_id", "from_id"),
Projection.toEndpoint("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: addN + addE in one batch with cross-entry Var reference
const createUserParams = defineParams({
userId: param.string(),
name: param.string(),
postId: param.array(param.i64()),
});
function createUserAndLinkPost(p = createUserParams) {
return writeBatch()
.varAs(
"newUser",
g()
.addN("User", {
userId: PropertyInput.param("userId"),
name: PropertyInput.param("name"),
createdAt: PropertyInput.expr(Expr.timestamp()),
})
.project([PropertyProjection.renamed("$id", "id")]),
)
.varAs("link", g().n(NodeRef.param("postId")).addE("CREATED_BY", NodeRef.var("newUser"), {}))
.returning(["newUser", "link"]);
}---
13. Write: upsert via varAsIf
const upsertParams = defineParams({ userId: param.string(), name: param.string() });
function upsertUser(p = upsertParams) {
return writeBatch()
.varAs("existing", g().nWithLabel("User").where(Predicate.eqParam("userId", "userId")))
.varAsIf(
"updated",
BatchCondition.varNotEmpty("existing"),
g().n(NodeRef.var("existing")).setProperty("name", PropertyInput.param("name")),
)
.varAsIf(
"created",
BatchCondition.varEmpty("existing"),
g().addN("User", { userId: PropertyInput.param("userId"), name: PropertyInput.param("name") }),
)
.returning(["updated", "created"]);
}---
14. Write: forEachParam over an array of objects
const bulkParams = defineParams({ data: param.array(param.object(param.value())) });
function bulkCreateUsers(p = bulkParams) {
const body = writeBatch().varAs(
"created",
g().addN("User", {
externalId: PropertyInput.param("externalId"),
embedding: PropertyInput.param("embedding"),
}),
);
return writeBatch().forEachParam("data", body).returning(["created"]);
}Inside body, parameter names resolve against each object's fields. param.array(param.object(param.value())) records the parameter as {"Array": "Object"} on the wire — exactly the Rust QueryParamType::Array(Box::new(QueryParamType::Object)).
---
15. Nested object properties + dotted paths
function createUserWithMetadata() {
return writeBatch()
.varAs(
"user",
g()
.addN("User", {
userId: "u-42",
metadata: {
externalID: "crm-42",
score: 20,
tags: ["trial", 7],
},
})
.valueMap(["userId", "metadata.externalID"]),
)
.returning(["user"]);
}
function usersByExternalId() {
return readBatch()
.varAs(
"users",
g()
.nWithLabel("User")
.where(Predicate.eq("metadata.externalID", "crm-42"))
.project([
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
const filteredParams = defineParams({
statuses: param.array(param.string()),
since: param.dateTime(),
});
function usersFiltered(p = filteredParams) {
return readBatch()
.varAs(
"users",
g()
.nWithLabel("User")
.where(Predicate.and([Predicate.isInParam("status", "statuses"), Predicate.gteParam("createdAt", "since")]))
.values(["$id", "status", "createdAt"]),
)
.returning(["users"]);
}
const body = usersFiltered().toDynamicJson(filteredParams, {
statuses: ["active", "pending"],
since: DateTime.parseRfc3339("2026-04-05T10:00:00Z"),
});statuses records as {"Array": "String"} and since as "DateTime". Pass a DateTime; the request normalizes to UTC RFC3339 with millisecond precision before serializing.
---
17. Write: index management
function bootstrapIndexes() {
return writeBatch()
.varAs("idx_userId", g().createIndexIfNotExists(IndexSpec.nodeUniqueEquality("User", "userId")))
.varAs("idx_embedding", g().createIndexIfNotExists(IndexSpec.nodeVector("Document", "embedding", "tenantId")))
.varAs("idx_body", g().createIndexIfNotExists(IndexSpec.nodeText("Document", "body", "tenantId")))
.returning(["idx_userId", "idx_embedding", "idx_body"]);
}Drop an index with g().dropIndex(IndexSpec....). The convenience methods (createVectorIndexNodes, etc.) produce identical wire output — prefer createIndexIfNotExists + IndexSpec for consistency with the dynamic JSON reference.
---
18. Warm a read route
Warming uses the same query; .warmOnly() sets the X-Helix-Warm: true header on the client. Build the request and let callers decide to warm:
import { Client } from "@helix-db/helix-db";
const client = new Client("https://helix.example.com").withApiKey(apiKey);
const request = userById().toDynamicRequest(userByIdParams, { userId: "u-42" });
// .warmOnly() sets X-Helix-Warm: true. A successful warm returns 204 No Content; writes reject warming.
await client.query().warmOnly().dynamic(request).send();Warming is strictly read-only; a WriteBatch with X-Helix-Warm: true is rejected by the gateway.
---
Registering a bundle
Any of the parameterized builders above can be registered into a queries.json bundle in addition to being called dynamically:
export const queries = defineQueries({
read: {
user_by_id: registerRead(userById, userByIdParams),
nearest_documents: registerRead(nearestDocuments, nearestParams),
},
write: {
upsert_user: registerWrite(upsertUser, upsertParams),
bootstrap_indexes: registerWrite(bootstrapIndexes, defineParams({})),
},
});
queries.call.user_by_id({ userId: "u-42" }); // -> DynamicQueryRequest with query_name="user_by_id"
await queries.generate("queries.json"); // bundle, version 4Route names must be unique across read and write routes — duplicates throw GenerateError.
Helix Query Authoring — TypeScript DSL Reference
Exhaustive builder catalog for the @helix-db/helix-db TypeScript DSL. Use when SKILL.md points you at a category or when you need a signature confirmed. Categories line up 1:1 with ../helix-query-rust/REFERENCE.md and ../helix-query-json-dynamic/REFERENCE.md so you can jump between TypeScript, Rust, and JSON forms.
All signatures come from sdks/typescript/src/index.ts; line numbers are cited inline. The builder is intentionally close to the Rust enum names on the wire (e.g. Step, Predicate variants serialize identically) while exposing camelCase TypeScript methods. The compatibility target is structural JSON equality with Rust serde output — encoding rules live in ../helix-query-json-dynamic/REFERENCE.md.
Import
import { g, sub, readBatch, writeBatch, NodeRef, EdgeRef, Predicate, SourcePredicate,
PropertyValue, PropertyInput, Expr, StreamBound, PropertyProjection, ExprProjection,
Projection, RepeatConfig, IndexSpec, Order, EmitBehavior, AggregateFunction, CompareOp,
BatchCondition, DateTime, RangeIndexDirection, defineParams, param, registerRead, registerWrite, defineQueries,
serializeQueryBundle, stringifyJson, i64, f32, f64, bytes, dateTime } from "@helix-db/helix-db";A prelude object (src/index.ts:2467) re-exports all of the above for convenience.
Typestate Cheat Sheet
Traversal<S extends TraversalState, M extends MutationMode> (src/index.ts:1284) tracks state in the type system. TraversalState = "empty" | "nodes" | "edges" | "terminal" and MutationMode = "read" | "write" (src/index.ts:1281-1282).
empty -- n,nWhere,nWithLabel[Where],inject,addN,createIndexIfNotExists,dropIndex,
createVectorIndexNodes/Edges,createTextIndexNodes/Edges └─> nodes
empty -- e,eWhere,eWithLabel[Where] └─> edges
empty -- vectorSearchNodes[With], textSearchNodes[With] └─> nodes
empty -- vectorSearchEdges[With], textSearchEdges[With] └─> edges
nodes -- out, in, both, has, hasLabel, hasKey, where, dedup, within, without,
limit, skip, range, as, store, select, inject, orderBy[Multiple],
repeat, union, choose, coalesce, optional, path, simplePath,
fold, unfold, withSack, sack* ↻ nodes
nodes -- outE, inE, bothE └─> edges
nodes -- count, exists, id, label, values, valueMap, project, group,
groupCount, aggregateBy └─> terminal
nodes("write") -- addE, setProperty, removeProperty, drop, dropEdge,
dropEdgeLabeled, dropEdgeById ↻ nodes
edges -- outN, inN, otherN └─> nodes
edges -- has, hasLabel, hasKey, where, edgeHas, edgeHasLabel, dedup, within,
without, limit, skip, range, as, store, select, orderBy[Multiple] ↻ edges
edges -- count, exists, id, label, edgeProperties └─> terminalReadBatch.varAs accepts only Traversal<_, "read"> — both the compiler and a runtime guard reject a write traversal (src/index.ts:1840-1842). WriteBatch.varAs accepts either mode.
---
Entry Points
src/index.ts:1674, 1775, 1930, 1933:
g(): Traversal<"empty", "read">
sub(): SubTraversal
readBatch(): ReadBatch
writeBatch(): WriteBatchReadBatch / WriteBatch (src/index.ts:1832, 1880)
.varAs(name: string, traversal): ReadBatch | WriteBatch // store named result
.varAsIf(name: string, condition: BatchCondition, traversal) // conditional entry
.forEachParam(paramName: string, body): ReadBatch | WriteBatch // run body per object in array param
.returning(vars: Iterable<string>) // restrict response variables
.toJsonString(): string // raw batch JSON (inline query body)
.toJsonBytes(): Uint8Array
.toDynamicJson(options?: DynamicQueryOptions): string // no-param dynamic request JSON
.toDynamicJson(params: DefinedParams<T>, values: ParamInputs<T>, options?: DynamicQueryOptions): string
.toDynamicRequest(..., options?: DynamicQueryOptions): DynamicQueryRequest
.toDynamicBytes(..., options?: DynamicQueryOptions): Uint8ArrayBatchCondition (src/index.ts:1779)
BatchCondition.varNotEmpty(name) // {"VarNotEmpty": name}
BatchCondition.varEmpty(name) // {"VarEmpty": name}
BatchCondition.varMinSize(name, n) // {"VarMinSize": [name, n]}
BatchCondition.prevNotEmpty() // "PrevNotEmpty"NamedQuery (src/index.ts:1805) and BatchEntry (src/index.ts:1816, .query(...) / .forEach(...)) are built for you by varAs / forEachParam — you rarely construct them directly.
---
Scalar Constructors & Values
Literal helpers (src/index.ts:288-300) disambiguate numeric width:
i64(value: number | bigint) f32(value: number) f64(value: number)
bytes(value: Uint8Array | number[]) dateTime(value: DateTime)PropertyValue (src/index.ts:326) — tagged on the wire
PropertyValue.null() // "Null"
PropertyValue.bool(b) // {"Bool": b}
PropertyValue.i64(n) // {"I64": n} (number | bigint)
PropertyValue.f64(n) PropertyValue.f32(n)
PropertyValue.string(s) // {"String": s}
PropertyValue.bytes(u8) // {"Bytes": [...]}
PropertyValue.dateTime(dt | ms) PropertyValue.datetimeMillis(ms)
PropertyValue.i64Array(xs) PropertyValue.f64Array(xs) PropertyValue.f32Array(xs) PropertyValue.stringArray(xs)
PropertyValue.array(xs) PropertyValue.object(record)
PropertyValue.from(input) // smart conversion from PropertyValueInput
// accessors: asStr, asI64, asDatetimeMillis, asF64, asBool, asArray, asObjectPropertyValueInput (src/index.ts:307) is the union accepted wherever a literal is allowed: null | boolean | number | bigint | string | Uint8Array | DateTime | PropertyValue | arrays | { object: ... }. Objects and generic arrays are stored as property values. Homogeneous primitive arrays may use the typed array variants (I64Array, F64Array, StringArray); mixed or nested arrays use PropertyValue.array(...).
PropertyInput (src/index.ts:431) — value-or-expression
Used for write property values and edgeHas / search args:
PropertyInput.value(v: PropertyValueInput) // {"Value": <PropertyValue>}
PropertyInput.expr(e: Expr) // {"Expr": <Expr>}
PropertyInput.param(name: string) // {"Expr": {"Param": name}}
PropertyInput.from(input) // smart constructorDateTime (src/index.ts:239)
DateTime.fromMillis(ms: number | bigint) DateTime.parseRfc3339(s: string)
.millis(): bigint .toRfc3339(): string // UTC, millisecond precision; negative epochs supported---
References: Nodes & Edges
NodeRef (src/index.ts:459) / EdgeRef (src/index.ts:490):
NodeRef.all() // "All" (nodes only)
NodeRef.id(id) // {"Ids": [id]}
NodeRef.ids(iterable) // {"Ids": [...]}
NodeRef.var(name) // {"Var": name}
NodeRef.param(name) // {"Param": name}
NodeRef.from(value) // accepts NodeRef | id | id[] | "var-name"
// EdgeRef: id, ids, var, param, from (no `all`)g().n(...) accepts NodeRef | NodeId | NodeId[] | string; g().e(...) accepts EdgeRef | EdgeId | EdgeId[]. NodeId/EdgeId are number | bigint.
---
Sources (Traversal<"empty"> → Traversal<"nodes"|"edges">)
src/index.ts:1329-1476:
g().n(nodes) -> Traversal<"nodes">
g().nWhere(pred: SourcePredicate) -> Traversal<"nodes">
g().nWithLabel(label) -> Traversal<"nodes"> // = nWhere(SourcePredicate.eq("$label", label))
g().nWithLabelWhere(label, pred) -> Traversal<"nodes"> // = nWhere(and([eq($label,label), pred]))
g().e(edges) -> Traversal<"edges">
g().eWhere(pred) g().eWithLabel(label) g().eWithLabelWhere(label, pred)
// Vector & text search (high-level: concrete vector + numeric k)
g().vectorSearchNodes(label, property, queryVector: number[], k: number, tenantValue?: PropertyValueInput | null)
g().textSearchNodes(label, property, queryText: string, k: number, tenantValue?: PropertyValueInput | null)
g().vectorSearchEdges(...) g().textSearchEdges(...)
// `*With` variants (parameterized): accept PropertyInput | Expr | ParamRef | PropertyValueInput,
// k accepts StreamBound | Expr | ParamRef | number | bigint, tenantValue accepts the same (or null)
g().vectorSearchNodesWith(label, property, queryVector, k, tenantValue?)
g().textSearchNodesWith(label, property, queryText, k, tenantValue?)
g().vectorSearchEdgesWith(...) g().textSearchEdgesWith(...)Prefer the *With variants for parameterized routes. The high-level vectorSearchNodes wraps queryVector as PropertyValue.f32Array and k as StreamBound.literal.
---
Traversal
Node-stream navigation (src/index.ts Traversal class):
.out(label?: string) .in(label?: string) .both(label?: string) -> Traversal<"nodes", M>
.outE(label?: string) .inE(label?: string) .bothE(label?: string) -> Traversal<"edges", M>Edge-stream navigation:
.outN() -> Traversal<"nodes", M> // edge → target
.inN() -> Traversal<"nodes", M> // edge → source
.otherN() -> Traversal<"nodes", M> // edge → "other" endpointThe label argument is optional; omit it (out()) or pass a string (out("FOLLOWS")). On the wire, out() → {"Out": null}, out("FOLLOWS") → {"Out": "FOLLOWS"}.
---
Filters
.has(prop, value: PropertyValueInput) // both node & edge streams
.hasLabel(label)
.hasKey(prop)
.where(pred: Predicate)
.dedup()
.within(varName) .without(varName)
.edgeHas(prop, value: PropertyInput | PropertyValueInput) // edge streams
.edgeHasLabel(label) // edge streamsOn edge streams, generic .has, .hasLabel, .hasKey, and .where filter stored edge properties plus virtual fields $id, $label, $from, $to, $distance, and $score. Keep .edgeHas for edge filters whose right-hand side must be a PropertyInput expression or runtime parameter.
Predicate (src/index.ts:624)
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.hasKey(prop) Predicate.isNull(prop) Predicate.isNotNull(prop)
Predicate.startsWith(prop, s) Predicate.endsWith(prop, s) Predicate.contains(prop, s) Predicate.containsParam(prop, paramName)
Predicate.isIn(prop, values) Predicate.isInExpr(prop, expr | paramRef) Predicate.isInParam(prop, paramName)
Predicate.and(preds) Predicate.or(preds) Predicate.not(pred)
Predicate.compare(left: Expr, op: CompareOp, right: Expr)Parameterized comparison shortcuts (wrap Compare):
Predicate.eqParam(prop, paramName) Predicate.neqParam(...)
Predicate.gtParam(...) Predicate.gteParam(...) Predicate.ltParam(...) Predicate.lteParam(...)SourcePredicate (src/index.ts:722) — used in nWhere / eWhere
Index-friendly subset:
SourcePredicate.eq / neq / gt / gte / lt / lte / between / hasKey / startsWith / and / orEach comparison auto-routes by argument type: a literal keeps the plain variant (SourcePredicate.eq("u","alice") → {"Eq": ["u", {"String": "alice"}]}); an Expr/ParamRef routes to the *Expr variant (SourcePredicate.eq("u", Expr.param("name")) → {"EqExpr": ["u", {"Param": "name"}]}). .toPredicate() converts *Expr variants to Compare. Not available at source position: isNull, isNotNull, contains[Param], endsWith, isIn*, 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 (src/index.ts:517)
CompareOp.Eq | Neq | Gt | Gte | Lt | Lte---
Expressions
Expr (src/index.ts:543):
Expr.prop(name) Expr.val(value: PropertyValueInput)
Expr.id() Expr.param(name)
Expr.timestamp() // server UTC epoch millis
Expr.datetime() // server typed DateTime
expr.add(other) expr.sub(other) expr.mul(other) expr.div(other) expr.modulo(other) expr.neg()
Expr.case(whenThen: [Predicate, Expr][], elseExpr?: Expr | null)ParamRef (src/index.ts:2048) has .toExpr() so a param reference can be used where an Expr is expected.
Typical uses:
Predicate.compare(Expr.prop("age"), CompareOp.Gte, Expr.param("minAge"))— property-to-parameter comparison.Expr.prop("metadata.score")— nested object field lookup with the same exact-first dotted-path rules as filters.ExprProjection.new("age2", Expr.prop("age").add(Expr.val(1)))— computed column.g().addN("Foo", { createdAt: PropertyInput.expr(Expr.timestamp()) })— server-side timestamp.
---
Stream Bounds & Limits
.limit(n) .skip(n) .range(start, end)Each accepts number, bigint, Expr, ParamRef, or StreamBound. StreamBound (src/index.ts:596):
StreamBound.literal(n) // {"Literal": n}
StreamBound.expr(e) // {"Expr": <Expr>}
StreamBound.from(value) // negative numbers become Expr (e.g. -1 -> {"Expr": {"Constant": {"I64": -1}}})---
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" -> "nodes" source formCross-entry references: NodeRef.var(name), EdgeRef.var(name), NodeRef.param(name), EdgeRef.param(name).
---
Ordering
.orderBy(property, order: Order) // Order.Asc | Order.Desc
.orderByMultiple([[prop1, Order.Desc], [prop2, Order.Asc]])Order at src/index.ts:525. Dotted paths such as metadata.score are valid for fallback ordering, but V1 range indexes cannot accelerate nested paths.
---
Aggregation (terminals)
.count() .exists() .group(property) .groupCount(property)
.aggregateBy(fn: AggregateFunction, property)
// AggregateFunction.{Count, Sum, Min, Max, Mean} (src/index.ts:535)---
Branching
Each arm is a SubTraversal from sub() (src/index.ts:1678):
.union([subA, subB, ...])
.choose(condition: Predicate, thenTraversal: SubTraversal, elseTraversal?: SubTraversal | null)
.coalesce([subA, subB, ...]) // first non-empty wins
.optional(subA) // pass through if subA is emptySubTraversal supports: out, in, both, outE, inE, bothE, outN, inN, otherN, has, hasLabel, hasKey, where, dedup, within, without, edgeHas, edgeHasLabel, limit, skip, range, as, store, select, orderBy, orderByMultiple, path, simplePath.
---
Repeat
.repeat(RepeatConfig.new(sub().out("KNOWS")).times(3))
.repeat(
RepeatConfig.new(sub().out("REPORTS_TO"))
.until(Predicate.eq("title", "CEO"))
.emitAfter()
.maxDepth(10),
)RepeatConfig (src/index.ts:884):
.times(n)— fixed iterations.until(pred)— stop when predicate is true.emitAll()/.emitBefore()/.emitAfter()— emit policy.emitIf(pred)— emit only matching elements (sets emit toAfter).maxDepth(n)— safety cap (default 100)
Default emit is EmitBehavior.None (src/index.ts:529; only the final result). Bound every repeat with times or until.
---
Projections (terminals)
.values(["name", "email"]) -> Traversal<"terminal", M>
.valueMap(["$id", "name"]) -> Traversal<"terminal", M>
.valueMap(null) -> all properties
.project([...]) -> Traversal<"terminal", M>
.edgeProperties() -> Traversal<"terminal", M> // edge streams onlyProjection constructors (src/index.ts:837, 853, 868) — all #[serde(untagged)] on the wire (no variant tag):
PropertyProjection.new("name") // {source:"name", alias:"name"}
PropertyProjection.renamed("$distance", "distance") // {source:"$distance", alias:"distance"}
ExprProjection.new("age2", Expr.prop("age").add(Expr.val(1))) // {alias:"age2", expr:{...}}
Projection.property("source", "alias")
Projection.expr("alias", expr)
Projection.fromEndpoint("resource_id", "from_id")
Projection.toEndpoint("resource_id", "to_id")
Projection.from(value)Mix PropertyProjection and ExprProjection freely in .project([...]). Filtered values(...), filtered valueMap(...), PropertyProjection.source, and Expr.prop(...) accept dotted object paths. valueMap(null) returns all top-level stored properties as-is and does not flatten nested objects.
On edge streams, Projection.fromEndpoint(prop, alias) serializes to {"source":"$from.<prop>","alias":"<alias>"} and Projection.toEndpoint(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 .edgeProperties() for full edge maps and the internal $from / $to node ids.
---
Terminals (metadata)
.count() .exists() .id() .label()Usable on node and edge streams. .edgeProperties() is edge-only.
---
Mutations (write-only)
Source-position mutation (Traversal<"empty"> → Traversal<"nodes", "write">):
g().addN(label, properties) // properties: Record<string, PropertyInput|PropertyValueInput|ParamRef> OR [string, ...][]
g().dropEdgeById(edges)
g().inject(varName)Node-state mutations (→ Traversal<"nodes", "write">):
.addE(label, to: NodeRef | NodeId | ..., properties)
.setProperty(name, value: PropertyInput | PropertyValueInput)
.removeProperty(name)
.drop()
.dropEdge(to) .dropEdgeLabeled(to, label) .dropEdgeById(edges)addN/addE properties accept an object ({ name: "Alice" }) or an array of tuples ([["name", "Bob"]]); values may be raw literals, nested objects/arrays, PropertyInput.param(...), or a ParamRef. On the wire each becomes ["name", {"Value": {"String": "Alice"}}] or, for nested values, a tagged {"Object": ...} / {"Array": ...} PropertyValue.
---
Indexes (write-only)
g().createIndexIfNotExists(spec: IndexSpec) -> Traversal<"terminal", "write">
g().dropIndex(spec: IndexSpec) -> Traversal<"terminal", "write">
// convenience source forms (tenantProperty optional)
g().createVectorIndexNodes(label, property, tenantProperty?)
g().createVectorIndexEdges(label, property, tenantProperty?)
g().createTextIndexNodes(label, property, tenantProperty?)
g().createTextIndexEdges(label, property, tenantProperty?)IndexSpec constructors (src/index.ts:963):
IndexSpec.nodeEquality(label, property) // unique = false
IndexSpec.nodeUniqueEquality(label, property) // unique = true
IndexSpec.nodeRange(label, property)
IndexSpec.nodeRangeDesc(label, property)
IndexSpec.nodeRangeWithDirection(label, property, RangeIndexDirection.Desc)
IndexSpec.edgeEquality(label, property)
IndexSpec.edgeRange(label, property)
IndexSpec.edgeRangeDesc(label, property)
IndexSpec.edgeRangeWithDirection(label, property, RangeIndexDirection.Desc)
IndexSpec.nodeVector(label, property, tenantProperty?)
IndexSpec.nodeText(label, property, tenantProperty?)
IndexSpec.edgeVector(label, property, tenantProperty?)
IndexSpec.edgeText(label, property, tenantProperty?)Range indexes default to ascending physical order. Use RangeIndexDirection.Desc for descending indexes that primarily serve newest-first or high-score-first scans.
createVectorIndexNodes(...) serializes identically to createIndexIfNotExists(IndexSpec.nodeVector(...)) — {"CreateIndex": {"spec": {"NodeVector": {...}}, "if_not_exists": true}}. 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() .simplePath()
.withSack(initial) .sackSet(prop) .sackAdd(prop) .sackGet()---
Raw Step factory
Step (src/index.ts:1002) exposes every AST step as a static factory (Step.n, Step.out, Step.vectorSearchEdges, Step.addN, Step.createVectorIndexNodes, Step.inject, …) for building step lists directly. Most code should use the fluent Traversal methods; reach for Step only when assembling steps programmatically. traversal.intoSteps() returns the underlying Step[].
---
Parameters
param schema constructors (src/index.ts:2033):
param.bool() param.i64() param.f64() param.f32() param.string()
param.dateTime() param.bytes() param.value()
param.object() param.object(inner) param.array(inner)defineParams(schema) (src/index.ts:2068) returns a DefinedParams<T> — an object of typed ParamRefs (p.limit, p.tenantId) plus hidden metadata. Pass it as the default argument of a builder function (function f(p = params) { ... }). A ParamRef (src/index.ts:2048) can be used directly where a StreamBound/Expr/property value is expected; .toExpr() converts it explicitly.
QueryParamType (src/index.ts:1937) is the on-the-wire parameter type: unit scalars serialize as bare strings ("String", "I64", "DateTime", …); array is a single-field tuple ({"Array": "String"}).
param.bytes() cannot be sent through the dynamic route — conversion throws DynamicQueryError (UnsupportedBytesParameter).
---
Registration & Bundles
registerRead(builder, params): RegisteredReadQuery // src/index.ts:2299
registerWrite(builder, params): RegisteredWriteQuery // src/index.ts:2308
const queries = defineQueries({ // src/index.ts:2416
read: { route_a: registerRead(builderA, paramsA) },
write: { route_b: registerWrite(builderB, paramsB) },
});
queries.call.route_a({ ... }) // -> DynamicQueryRequest with query_name="route_a" (typed input; unknown keys throw TypeError)
queries.buildQueryBundle() // -> QueryBundle (version 4)
await queries.generate("queries.json") // write bundle to path
serializeQueryBundle(bundle) // src/index.ts:2439 (pretty JSON string)
deserializeQueryBundle(json) // src/index.ts:2443 (validates version)DefinedQueries is at src/index.ts:2329; QUERY_BUNDLE_VERSION = 4 at src/index.ts:2250; QueryBundle shape (version, read_routes, write_routes, read_parameters, write_parameters) at src/index.ts:2252. Route names must be unique across read + write — duplicates throw GenerateError (src/index.ts:197).
---
Dynamic Requests
type DynamicQueryOptions = { queryName?: string | null }
DynamicQueryRequest.read(batch: ReadBatch, queryName?: string | null) // src/index.ts:2191
DynamicQueryRequest.write(batch: WriteBatch, queryName?: string | null)
req.insertParameterValue(name, value) req.insertParameterType(name, ty)
req.withParameterValue(name, value) req.withParameterType(name, ty)
req.setQueryName(name) req.clearQueryName()
req.withQueryName(name)
req.toJsonString() req.toJsonBytes()
// req.requestType -> "read" | "write" (DynamicQueryRequestType, src/index.ts:2174, lowercase on the wire)
// req.queryName -> string | null (serialized as top-level query_name)Most code reaches dynamic requests through batch.toDynamicJson(params, values, { queryName }) / .toDynamicRequest(...) or queries.call.route(...), which fill parameters and parameter_types automatically. Direct unnamed requests serialize query_name: null; queries.call.route(...) sets query_name to the registered route key automatically.
DynamicQueryValue (src/index.ts:2179) provides bare-JSON value helpers (.null/.bool/.i64/.f64/.f32/.string/.array/.object) for the top-level parameters map — these are untagged, distinct from the tagged PropertyValue used inside the AST.
For the exact JSON wire encoding these produce (externally-tagged enums, untagged Projection/BatchQuery/DynamicQueryValue, parameter_types rules, DateTime coercion), see ../helix-query-json-dynamic/REFERENCE.md.
---
Client (sending requests)
Built-in HTTP client for running a request against a Helix instance. Uses the global fetch, so there are no extra dependencies. Strict port of the Rust helix_db::Client.
new Client(url?: string | null) // default "http://localhost:6969"; throws HelixError (InvalidUrl) on a bad URL
.withApiKey(key?: string | null) // Authorization: Bearer <key> (null/undefined clears it)
.query<R = unknown>() // -> QueryBuilder<R>
// QueryBuilder<R> — request headers + body, then pick a route:
.writerOnly() // X-Helix-Require-Writer: true
.warmOnly() // X-Helix-Warm: true
.shouldAwaitDurability(b: boolean) // X-Helix-Await-Durable: true|false
.body(data: unknown) // JSON body for a stored route (bigint-safe)
.dynamic(req: DynamicQueryRequest) // -> QueryRequest<R> (POST /v1/query)
.stored(name: string) // -> QueryRequest<R> (POST /v1/query/{name})
await request.send(): Promise<R> // 200 -> parsed JSON (parseJsonStructural); any other status -> throws HelixErrorPrefer .shouldAwaitDurability(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.
import { Client, HelixError } from "@helix-db/helix-db";
const client = new Client("https://helix.example.com").withApiKey(apiKey);
const users = await client
.query<UserRow[]>()
.dynamic(findUsers().toDynamicRequest(params, { tenantId: "acme", limit: 25n }))
.send();Only HTTP 200 is treated as success (mirrors the Rust client). Build the DynamicQueryRequest argument with batch.toDynamicRequest(...) or queries.call.route(...).
---
Errors
HelixError(src/index.ts) — raised byClient/send().kind∈Network | Remote | Serialization | InvalidUrl;Remotecarries the server response body indetails.DynamicQueryError(src/index.ts:158) —kind∈Serialize | Utf8 | UnsupportedBytesParameter | InvalidDateTimeParameter.GenerateError(src/index.ts:197) —kind∈DuplicateQueryName | Io | Json | UnsupportedVersion.
---
Enums
CompareOp.{Eq, Neq, Gt, Gte, Lt, Lte} // src/index.ts:517
Order.{Asc, Desc} // src/index.ts:525 (bare strings on the wire)
EmitBehavior.{None, Before, After, All} // src/index.ts:529
AggregateFunction.{Count, Sum, Min, Max, Mean} // src/index.ts:535
DynamicQueryRequestType.{Read, Write} // src/index.ts:2174 (lowercase on the wire)---
JSON Utilities
stringifyJson(value, pretty?), parseJsonStructural(json), structuralJsonEqual(a, b), canonicalizeJson(value) (src/index.ts:48-69). Use stringifyJson (or toJsonString / serializeQueryBundle) instead of raw JSON.stringify whenever a payload may contain bigint.
---
Rust ↔ TypeScript Naming Map
| Rust | TypeScript |
|---|---|
read_batch() / write_batch() | readBatch() / writeBatch() |
var_as(...) / var_as_if(...) | varAs(...) / varAsIf(...) |
for_each_param(...) | forEachParam(...) |
n_with_label[_where] | nWithLabel[Where] |
in_ | in |
where_(...) | where(...) |
value_map(...) | valueMap(...) |
order_by[_multiple] | orderBy[Multiple] |
NodeRef::var(...) | NodeRef.var(...) |
SourcePredicate::eq(...) | SourcePredicate.eq(...) |
Predicate::eq_param(...) | Predicate.eqParam(...) |
vector_search_nodes_with(...) | vectorSearchNodesWith(...) |
#[register] fn + fn params | defineParams(...) + registerRead/registerWrite |
DynamicQueryRequest::read(b).with_query_name("route").to_json_string() | batch.toDynamicJson(params, values, { queryName: "route" }) |
Client::new(Some(url))? / .with_api_key(...) | new Client(url) / .withApiKey(...) |
client.query().warm_only().dynamic(r).send() | client.query().warmOnly().dynamic(r).send() |
The wire output (enum tags, field names, omitted/null fields) is identical between the two DSLs — only the surface naming differs.