Execute
Execute operations run your query and retrieve results. Use execute() for simple queries,
paginate() for cursor-based pagination, and stream() for processing large datasets.
execute()
Section titled “execute()”Run the query and return all results:
const results = await store .query() .from("Person", "p") .whereNode("p", (p) => p.status.eq("active")) .select((ctx) => ctx.p) .execute();
// results: readonly Person[]Return Type
Section titled “Return Type”Returns a readonly array of the selected type:
// TypeScript infers the shape from your selectionconst results = await store .query() .from("Person", "p") .select((ctx) => ({ name: ctx.p.name, email: ctx.p.email, })) .execute();
// results: readonly { name: string; email: string | undefined }[]first()
Section titled “first()”Get the first result or undefined:
const alice = await store .query() .from("Person", "p") .whereNode("p", (p) => p.email.eq("alice@example.com")) .select((ctx) => ctx.p) .first();
if (alice) { console.log(alice.name);}count()
Section titled “count()”Count matching results without fetching data:
const activeCount = await store .query() .from("Person", "p") .whereNode("p", (p) => p.status.eq("active")) .count();
// activeCount: numberexists()
Section titled “exists()”Check if any results exist:
const hasActiveUsers = await store .query() .from("Person", "p") .whereNode("p", (p) => p.status.eq("active")) .exists();
// hasActiveUsers: booleanCursor Pagination
Section titled “Cursor Pagination”For large datasets, cursor-based pagination is more efficient than limit/offset. It uses keyset
pagination which doesn’t degrade as you go deeper.
paginate()
Section titled “paginate()”const firstPage = await store .query() .from("Person", "p") .select((ctx) => ({ id: ctx.p.id, name: ctx.p.name, })) .orderBy("p", "name", "asc") // ORDER BY required .paginate({ first: 20 });Pagination Result Shape
Section titled “Pagination Result Shape”{ data: readonly T[], // The actual results hasNextPage: boolean, // More results available forward hasPrevPage: boolean, // More results available backward nextCursor: string | undefined, // Opaque cursor for next page prevCursor: string | undefined, // Opaque cursor for previous page}Forward Pagination
Section titled “Forward Pagination”Use first and after to paginate forward:
// Get first pageconst page1 = await query.paginate({ first: 20 });
// Get next page using the cursorif (page1.hasNextPage && page1.nextCursor) { const page2 = await query.paginate({ first: 20, after: page1.nextCursor, });}Backward Pagination
Section titled “Backward Pagination”Use last and before to paginate backward:
// Get last pageconst lastPage = await query.paginate({ last: 20 });
// Get previous pageif (lastPage.hasPrevPage && lastPage.prevCursor) { const prevPage = await query.paginate({ last: 20, before: lastPage.prevCursor, });}Pagination Parameters
Section titled “Pagination Parameters”| Parameter | Type | Description |
|---|---|---|
first |
number |
Number of results from the start |
after |
string |
Cursor to start after (forward pagination) |
last |
number |
Number of results from the end |
before |
string |
Cursor to start before (backward pagination) |
Pagination with Traversals
Section titled “Pagination with Traversals”Pagination works with graph traversals:
const employeesPage = await store .query() .from("Company", "c") .whereNode("c", (c) => c.name.eq("Acme Corp")) .traverse("worksAt", "e", { direction: "in" }) .to("Person", "p") .select((ctx) => ({ id: ctx.p.id, name: ctx.p.name, role: ctx.e.role, })) .orderBy("p", "name", "asc") .paginate({ first: 50 });Streaming
Section titled “Streaming”For very large datasets, use streaming to process results without loading everything into memory.
stream()
Section titled “stream()”const stream = store .query() .from("Event", "e") .select((ctx) => ctx.e) .orderBy("e", "createdAt", "desc") // ORDER BY required .stream({ batchSize: 1000 });
// Process results as they arrivefor await (const event of stream) { console.log(event.title); await processEvent(event);}Batch Size
Section titled “Batch Size”The batchSize option controls how many records are fetched per database query:
// Smaller batches: Lower memory usage, more database queries.stream({ batchSize: 100 })
// Larger batches: Higher memory usage, fewer database queries.stream({ batchSize: 5000 })
// Default is 1000.stream()Streaming with Processing
Section titled “Streaming with Processing”async function exportAllUsers(): Promise<void> { const stream = store .query() .from("User", "u") .whereNode("u", (u) => u.status.eq("active")) .select((ctx) => ({ id: ctx.u.id, email: ctx.u.email, name: ctx.u.name, })) .orderBy("u", "id", "asc") .stream({ batchSize: 500 });
let count = 0; for await (const user of stream) { await exportToExternalSystem(user); count++; if (count % 1000 === 0) { console.log(`Exported ${count} users...`); } } console.log(`Export complete: ${count} users`);}Batch Execution
Section titled “Batch Execution”When you need multiple independent queries with different result types, use store.batch() to run
them in sequence against one target.
batch() does not batch round trips. The portable guarantee is that at most one query is in flight
at a time — at least one statement each, and two for a query whose selective-field mapping falls
back after its statement has already run. On a SQL backend with transactions it frames them with
begin/commit, putting a networked one at N+2 round trips at best; Durable Objects use an
ambient storage transaction with no framing, and without transactions there is no framing at all.
Connection reuse is the adapter’s business either way.
It will not fix an N+1. For that, fold the work into one query: a .traverse() chain (one
statement), store.subgraph() (2 statements on SQLite, 3 on PostgreSQL), or getByIds() /
bulkFindByIndex(), which are chunked rather than fixed-cost.
It is also not a snapshot: PostgreSQL defaults to read-committed isolation, so a later query can
observe a commit the earlier ones did not. There is no way to fix that for fluent queries today —
store.transaction() accepts an isolationLevel, but its context exposes only nodes / edges,
so a query builder cannot run inside it. Collection reads can get a snapshot via
store.transaction(fn, { isolationLevel: "repeatable_read" }) and tx.nodes / tx.edges — but
only where the backend has transactions (other backends refuse before invoking fn), and a history-enabled store on
PostgreSQL additionally requires accessMode: "read_only" or the call throws.
const [people, companies] = await store.batch( store .query() .from("Person", "p") .whereNode("p", (p) => p.status.eq("active")) .select((ctx) => ({ id: ctx.p.id, name: ctx.p.name })), store .query() .from("Company", "c") .select((ctx) => ({ id: ctx.c.id, name: ctx.c.name })) .orderBy("c", "name", "asc") .limit(5),);// people: readonly { id: string; name: string }[]// companies: readonly { id: string; name: string }[]Each query preserves its own projection, filtering, sorting, and pagination. Results are returned as a typed tuple matching the input order.
Edge collection batchFind* methods also return BatchableQuery and can be mixed freely with
fluent queries — each still costs its own statement:
const [skills, employer] = await store.batch( store.edges.hasSkill.batchFindFrom(alice), store.edges.worksAt.batchFindFrom(alice),);vs Promise.all: workload- and adapter-dependent in both directions. Promise.all overlaps its
queries against a pool with idle capacity, but it does not necessarily hold N connections, and
against a single client or a saturated pool it queues. batch() keeps at most one query in flight,
so it pays the sum of their latencies — but it can still come out ahead where connection
acquisition dominates. Measure rather than assume.
vs transaction(): same transaction, lighter API — no callback, typed tuple return. But
transaction() is the only one that takes an isolationLevel, and it cannot run fluent queries.
See Batch Query Execution for full API reference.
Prepared Queries
Section titled “Prepared Queries”Prepared queries let you build and structurally validate a query’s AST once — so a malformed query
fails fast, before the first .execute() — and execute it many times with different parameter
values.
param(name)
Section titled “param(name)”Use param() to declare a named placeholder inside any predicate position:
import { param } from "@nicia-ai/typegraph";prepare()
Section titled “prepare()”Call .prepare() on an executable query to build and validate the AST once. Returns a
PreparedQuery<R> that can be executed with different bindings. The statement is compiled once into
a cached template and reused by every .execute() call — see
Prepared query SQL compilation below for how that stays fresh.
const findByName = store .query() .from("Person", "p") .whereNode("p", (p) => p.name.eq(param("name"))) .select((ctx) => ctx.p) .prepare();
// Execute with different bindingsconst alices = await findByName.execute({ name: "Alice" });const bobs = await findByName.execute({ name: "Bob" });Parameterized Bounds
Section titled “Parameterized Bounds”Parameters work anywhere a scalar value is accepted:
const findByAge = store .query() .from("Person", "p") .whereNode("p", (p) => p.age.between(param("minAge"), param("maxAge"))) .select((ctx) => ctx.p) .prepare();
const youngAdults = await findByAge.execute({ minAge: 18, maxAge: 25 });const seniors = await findByAge.execute({ minAge: 65, maxAge: 120 });prepared.execute(bindings) validates bindings strictly: all declared parameters must be
provided, and unknown binding keys are rejected.
Supported Positions
Section titled “Supported Positions”param() works with any scalar predicate:
| Predicate | Example |
|---|---|
eq / neq |
p.name.eq(param("name")) |
gt / gte / lt / lte |
p.age.gt(param("minAge")) |
between |
p.age.between(param("lo"), param("hi")) |
contains |
p.name.contains(param("substr")) |
startsWith / endsWith |
p.name.startsWith(param("prefix")) |
like / ilike |
p.email.like(param("pattern")) |
in() and notIn() take a list-valued parameter — the whole list, not individual elements:
const byIds = store .query() .from("Person", "p") .whereNode("p", (p) => p.id.in(param("ids"))) .select((ctx) => ctx.p) .prepare();
await byIds.execute({ ids: ["a", "b", "c"] });await byIds.execute({ ids: ["d"] });The list is bound as a single parameter that the database unpacks, so the compiled SQL text does
not depend on the list’s length: one statement serves every arity, and a list of ten thousand ids
still costs one bound parameter rather than blowing past the engine’s bind limit. An empty list is
valid — in([]) matches nothing, notIn([]) matches everything.
Every element must be the field’s type, and numbers must be finite. A mixed list — [1, "a"] bound
against a number field — is rejected with a ConfigurationError before it reaches the database, on
every backend, as is NaN or Infinity. This matches the literal form, which already refuses a
mixed list, and it is what keeps the two backends in step: left unchecked, PostgreSQL would fail
casting while SQLite silently matched nothing.
Prepared Query SQL Compilation
Section titled “Prepared Query SQL Compilation”.prepare() builds and validates the AST once. On a backend that can compile and run raw SQL text
(both the SQLite and PostgreSQL backends can), the statement is then compiled once into a cached
template and reused by every .execute() call.
The subtlety a cache like that has to survive is freshness: a “current” (live) read filters on
temporal validity as of the instant it runs, so caching a compiled statement that had a concrete
“now” baked into it would freeze that instant for the prepared query’s entire lifetime — hiding
every row created after .prepare() from every subsequent call. The template therefore reserves the
read instant as a placeholder rather than a value, and each .execute() fills it with a fresh
instant alongside the call’s own bindings. Nothing about the statement’s text depends on either.
Two cases fall back to substituting parameters into the AST and compiling through the standard path on every call — same results and the same freshness guarantee, without the cached-template fast path:
executeRawis unavailable (a custom or async backend).- The statement’s execution semantics ride on the compiled SQL object rather than its text, which no
amount of
executeRawsupport changes. Approximate vector search (similarTo(..., { approximate: true })) carries the engine’s iterative-scan wrapper, andstore.subgraph()on PostgreSQL forces a custom plan for its id-array fetches. Flattening either to cacheable text would drop the behavior it depends on, so both are excluded deliberately.
Query Debugging
Section titled “Query Debugging”toAst()
Section titled “toAst()”Get the query AST for inspection:
const builder = store .query() .from("Person", "p") .whereNode("p", (p) => p.status.eq("active")) .select((ctx) => ctx.p);
const ast = builder.toAst();console.log(JSON.stringify(ast, null, 2));compile()
Section titled “compile()”Use toSQL() to render SQL for the Store’s configured dialect without
executing it:
const compiled = builder.toSQL();console.log("SQL:", compiled.sql);console.log("Parameters:", compiled.params);For adapter and tooling authors, builder.compile() returns TypeGraph’s
database-independent CompiledSelectSql fragment. It can be passed to a
GraphBackend or rendered explicitly with renderSqlite() or
renderPostgres(). It is intentionally not a Drizzle SQL object.
Useful for:
- Debugging query behavior
- Understanding performance characteristics
- Building custom query executors
Ordering Requirements
Section titled “Ordering Requirements”Both paginate() and stream() require an orderBy() clause:
// Required for pagination.orderBy("p", "name", "asc").paginate({ first: 20 });
// Required for streaming.orderBy("e", "createdAt", "desc").stream();Stable Ordering
Section titled “Stable Ordering”For deterministic pagination, include a unique field in your ordering:
.orderBy("p", "name", "asc").orderBy("p", "id", "asc") // Ensures stable orderingReal-World Examples
Section titled “Real-World Examples”Paginated API Endpoint
Section titled “Paginated API Endpoint”async function listUsers(cursor?: string, limit = 20) { const query = store .query() .from("User", "u") .whereNode("u", (u) => u.status.eq("active")) .select((ctx) => ({ id: ctx.u.id, name: ctx.u.name, email: ctx.u.email, })) .orderBy("u", "createdAt", "desc") .orderBy("u", "id", "desc");
const result = cursor ? await query.paginate({ first: limit, after: cursor }) : await query.paginate({ first: limit });
return { users: result.data, nextCursor: result.nextCursor, hasMore: result.hasNextPage, };}Batch Processing
Section titled “Batch Processing”async function processAllOrders() { const stream = store .query() .from("Order", "o") .whereNode("o", (o) => o.status.eq("pending")) .select((ctx) => ctx.o) .orderBy("o", "createdAt", "asc") .stream({ batchSize: 100 });
for await (const order of stream) { try { await fulfillOrder(order); await store.update("Order", order.id, { status: "fulfilled" }); } catch (error) { console.error(`Failed to process order ${order.id}:`, error); } }}Infinite Scroll
Section titled “Infinite Scroll”function useInfiniteUsers() { const [users, setUsers] = useState<User[]>([]); const [cursor, setCursor] = useState<string | undefined>(); const [hasMore, setHasMore] = useState(true);
async function loadMore() { const result = await store .query() .from("User", "u") .select((ctx) => ctx.u) .orderBy("u", "name", "asc") .paginate({ first: 20, after: cursor });
setUsers((prev) => [...prev, ...result.data]); setCursor(result.nextCursor); setHasMore(result.hasNextPage); }
return { users, loadMore, hasMore };}