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
execute them over a single connection with snapshot consistency.
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:
const [skills, employer] = await store.batch( store.edges.hasSkill.batchFindFrom(alice), store.edges.worksAt.batchFindFrom(alice),);vs Promise.all: batch() uses 1 connection instead of N and guarantees snapshot consistency.
vs transaction(): Same consistency, but cleaner API — no callback, typed tuple return.
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; SQL text is compiled fresh on each
.execute() call (see Prepared query SQL compilation below for
why).
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")) |
Prepared Query SQL Compilation
Section titled “Prepared Query SQL Compilation”.prepare() builds and validates the AST once, but does not cache compiled SQL text across
.execute() calls — each call recompiles fresh. This is deliberate: a “current” (live) read binds
its temporal-validity filter to the instant it’s compiled at. Caching the compiled text from
.prepare() time would freeze that instant for the prepared query’s entire lifetime, silently
hiding any row created after .prepare() ran from every subsequent .execute() call — exactly the
bug this behavior was fixed to avoid. Recompiling is pure, in-memory string-building with no I/O, so
the cost is negligible next to the query’s actual database round-trip.
When the backend supports executeRaw (both SQLite and PostgreSQL backends do), the freshly
compiled SQL text is sent directly to the database driver with substituted parameter values. When
executeRaw is unavailable, the prepared query substitutes parameters into the AST and compiles
through the standard path instead — same freshness guarantee, different code path.
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 };}