Skip to content

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.

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[]

Returns a readonly array of the selected type:

// TypeScript infers the shape from your selection
const 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 }[]

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 matching results without fetching data:

const activeCount = await store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.status.eq("active"))
.count();
// activeCount: number

Check if any results exist:

const hasActiveUsers = await store
.query()
.from("Person", "p")
.whereNode("p", (p) => p.status.eq("active"))
.exists();
// hasActiveUsers: boolean

For large datasets, cursor-based pagination is more efficient than limit/offset. It uses keyset pagination which doesn’t degrade as you go deeper.

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 });
{
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
}

Use first and after to paginate forward:

// Get first page
const page1 = await query.paginate({ first: 20 });
// Get next page using the cursor
if (page1.hasNextPage && page1.nextCursor) {
const page2 = await query.paginate({
first: 20,
after: page1.nextCursor,
});
}

Use last and before to paginate backward:

// Get last page
const lastPage = await query.paginate({ last: 20 });
// Get previous page
if (lastPage.hasPrevPage && lastPage.prevCursor) {
const prevPage = await query.paginate({
last: 20,
before: lastPage.prevCursor,
});
}
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 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 });

For very large datasets, use streaming to process results without loading everything into memory.

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 arrive
for await (const event of stream) {
console.log(event.title);
await processEvent(event);
}

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()
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`);
}

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 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.

Use param() to declare a named placeholder inside any predicate position:

import { param } from "@nicia-ai/typegraph";

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 bindings
const alices = await findByName.execute({ name: "Alice" });
const bobs = await findByName.execute({ name: "Bob" });

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.

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.

.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:

  • executeRaw is 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 executeRaw support changes. Approximate vector search (similarTo(..., { approximate: true })) carries the engine’s iterative-scan wrapper, and store.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.

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));

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

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();

For deterministic pagination, include a unique field in your ordering:

.orderBy("p", "name", "asc")
.orderBy("p", "id", "asc") // Ensures stable ordering
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,
};
}
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);
}
}
}
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 };
}
  • Order - Ordering and limiting results
  • Shape - Output transformation
  • Overview - Query categories reference