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 }[]executeChecked(expectedSchemaVersion)
Section titled “executeChecked(expectedSchemaVersion)”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.
first()
Section titled “first()”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()
Section titled “count()”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: 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”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.
paginate()
Section titled “paginate()”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 });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, });}Batch cursor pages with page()
Section titled “Batch cursor pages with page()”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.
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 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
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”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 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.nodes.Order.update(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 };}