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

Check a cached reconciled schema while reading data in one SQL statement:

const rows = await store.query()
.from("Person", "person")
.select((ctx) => ctx.person)
.executeChecked(store.reconciledSchema.version);

The active schema version and data come from the same statement snapshot. A mismatch throws SchemaChangedError with details.graphId, details.expected, and details.actual before calling the selector, even when the data query matches no rows. undefined means no active schema; it is distinct from version zero. On mismatch, reload the reconciled schema, rebuild the query against the reopened store, and retry. Retry in a new transaction if the old one holds a repeatable-read snapshot.

This is an explicit alternative to a standalone getCommittedSchemaVersion probe on the first relational query. It checks that statement only; subsequent request reads can observe later commits. It neither locks the schema nor replaces write fences, and does not alter store-open or application cache policies.

Checked reads fetch full rows and support relational traversals, ordering, offsets, and limits. Recursive and relevance-ranked queries are refused with ConfigurationError; use a separate probe for those. Named parameters must be bound as ordinary values before building the query. Bundled SQLite and PostgreSQL backends provide the required tableNames.schemaVersions binding. A custom backend without it is refused before executing SQL. A custom binding must name a relation with the standard graph_id, version, and is_active columns and one active row per graph, consistent with getActiveSchema.

Get the first selected result or undefined. An existing limit(0) remains empty; offset() is preserved. Add orderBy() when the choice of first row must be deterministic. Only the returned row is passed to the selector:

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 SQL rows without fetching their data or running a select() callback. count() and exists() are available before and after select(). They preserve groupBy(), having(), limit(), and offset(): a grouped query counts groups, and limit(0).count() returns zero. Traversals count match rows, so multiple relationships can count the same node more than once. Bind named parameters as concrete values before using these terminals:

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

Use first/after for forward pages or last/before for backward pages. Page sizes must be positive safe integers. Do not combine directions or add query-level limit()/offset() to a paginated or streamed query; those bounds are refused rather than silently discarded. Use limit()/offset() with execute() for offset 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.

Nullable sort values follow the same ordering across page boundaries as in execute(): ascending order places missing values last, and descending order places them first. Forward and backward cursors retain rows in both the missing-value and non-missing-value groups. Sort fields do not have to appear in the selected result; pagination retains them internally for its cursors.

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

paginate() executes immediately. Use page() to build the same cursor-bounded read without executing it, so the page can compose with other independent reads in one batchOnce() statement:

const people = store
.query()
.from("Person", "person")
.orderBy("person", "name")
.select((fields) => fields.person);
const [page, companies] = await store.batchOnce(() => [
people.page({ first: 20, after: cursor }),
store
.query()
.from("Company", "company")
.orderBy("company", "name")
.select((fields) => fields.company),
]);

The returned page has the same PaginatedResult shape as paginate(). A page read also has an execute() method for independent execution. As with every batchOnce() member, all reads must belong to the same graph and execution target, and the combined statement must fit the backend’s bind-parameter budget.

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 independent reads must share one database round trip, use store.batchOnce(). It embeds each read as a CTE and returns the independently typed results in input order. Fluent queries preserve explicit ordering even when the sort field is not selected. The callback’s scoped builder creates batch-scoped composable graph reads without adding parallel *Query methods to the executing Store API:

const [people, neighbors, neighborhood] = await store.batchOnce((read) => [
store.query().from("Person", "p").select((ctx) => ctx.p),
read.neighbors(person, { edges: ["knows"], limit: 5 }),
read.subgraph(person.id, { edges: ["knows"], maxDepth: 2 }),
]);

The callback can also return roots.map(...), a singleton, or an empty array. A nonempty batch is one statement with no sequential fallback; an empty batch executes no SQL. At most 500 reads may be planned, and the combined statement must fit the backend’s bind-parameter budget. Every response is materialized as JSON rather than streamed, so bound each member’s result explicitly. Response size is data-dependent; TypeGraph neither estimates it nor imposes a response-byte cap before execution.

For several independent subgraphs, use the runtime-array form to collapse their database round trips into one statement:

const subgraphs = await store.batchOnce((read) =>
roots.map((root) =>
read.subgraph(root.id, {
edges: ["knows"],
maxDepth: 2,
project: { nodes: { Person: ["name"] } },
}),
),
);

When compatible subgraphs have substantially overlapping neighborhoods and project meaningful payloads, opt into shared traversal and hydration:

const subgraphs = await store.batchOnce(
(read) =>
roots.map((root) =>
read.subgraph(root.id, {
edges: ["knows"],
maxDepth: 2,
project: { nodes: { Person: ["name", "profile"] } },
}),
),
{ shareSubgraphs: true },
);

The option groups only compatible subgraph reads and hydrates a shared entity once while preserving an independent result object for every request. The default remains independent subgraph plans in the same one statement. Sharing adds membership and reconstruction overhead, so enable it for measured overlap and payload shapes rather than assuming it is universally faster. See shared subgraph examples for overlapping biographies, disjoint neighborhoods, and identity-only results with different tradeoffs.

Tuple members may use different roots, edge sets, depths, windows, and projections when a page needs heterogeneous neighborhoods:

const [social, employment] = await store.batchOnce((read) => [
read.subgraph(person.id, {
edges: ["knows"],
maxDepth: 2,
edgeWindows: { knows: { limit: 20 } },
}),
read.subgraph(person.id, {
edges: ["worksAt"],
maxDepth: 1,
project: {
nodes: { Company: ["name", "industry"] },
edges: { worksAt: ["role"] },
},
}),
]);

The one-statement guarantee reduces round trips, which is often valuable for remote databases. By default it does not combine recursive plans or share hydration between overlapping subgraphs, and it does not promise less database work than direct store.subgraph() calls. The explicit shareSubgraphs option changes that planning choice for compatible subgraph members only.

Use store.batch() when the batch includes queued edge collection batchFind* reads or when sequential execution is the intended connection profile.

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.

Whole-node, whole-edge, and spread selections detected during planning use a full fetch from the start. A selector branch that depends on actual row values can still trigger the fallback.

It will not merge arbitrary promises or collection calls. Use fluent queries or the callback’s read.neighbors(), read.countNeighbors(), and read.subgraph() methods when independent result shapes must share its one statement. Other alternatives are a .traverse() chain, store.neighbors() or store.countNeighbors() (one statement each), store.subgraph() (2 statements on SQLite, 3 on PostgreSQL), or getByIds() / bulkFindByIndex(), which are chunked rather than fixed-cost. Direct store.subgraph() and batch-scoped read.subgraph() share validation, traversal, projection, and result semantics. The call context selects the physical execution contract: the direct read uses backend-tuned hydration, while the batch-scoped read is embedded into the batch’s single statement.

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. When several reads need one stable snapshot, run tx.query(), tx.neighbors(), tx.countNeighbors(), tx.subgraph(), or tx.batchOnce() inside store.transaction(fn, { isolationLevel: "repeatable_read" }). These reads are bound to the open transaction and see its earlier uncommitted writes. Transactions require a backend with interactive transaction support; a history-enabled store on PostgreSQL additionally requires accessMode: "read_only" for a read-only transaction.

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(): batch() may open an internal transaction only to serialize its statements. Use transaction() when reads must share an explicit isolation level or see writes made earlier in the callback. Its context supports fluent and set-oriented reads; tx.batchOnce() still emits exactly one statement.

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

Cursor pagination and streaming automatically append missing start-node identity keys: id ASC for a single kind, or kind ASC and id ASC for a multi-kind source. Existing caller-specified identity ordering is preserved. Offset pagination needs an explicit total ordering.

For deterministic offset pagination of one kind, include id 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.nodes.Order.update(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