Indexes
TypeGraph stores node and edge properties in a JSON props column. When you filter or order by
JSON properties at scale, you typically need expression indexes on those JSON paths.
TypeGraph includes built-in indexes for common access patterns (lookups by ID, edge traversals, temporal filtering), but application-specific indexes are up to you.
The @nicia-ai/typegraph/indexes entrypoint provides:
- Type-safe index definitions for node and edge schemas
- Dialect-specific DDL generation for PostgreSQL and SQLite
- Drizzle schema integration so drizzle-kit can generate migrations
- Profiler integration so recommendations account for indexes you already have
Quick Start (Drizzle / drizzle-kit)
Section titled “Quick Start (Drizzle / drizzle-kit)”Define your indexes once and pass them into the Drizzle schema factories:
import { defineEdge, defineNode } from "@nicia-ai/typegraph";import { createPostgresTables } from "@nicia-ai/typegraph/adapters/drizzle/postgres";import { andWhere, defineEdgeIndex, defineNodeIndex } from "@nicia-ai/typegraph/indexes";import { z } from "zod";
const Person = defineNode("Person", { schema: z.object({ email: z.string().email(), name: z.string(), isActive: z.boolean().optional(), }),});
const worksAt = defineEdge("worksAt", { schema: z.object({ role: z.string(), }),});
export const personEmail = defineNodeIndex(Person, { fields: ["email"], unique: true, coveringFields: ["name"], where: (w) => andWhere(w.deletedAt.isNull(), w.isActive.eq(true)),});
export const worksAtRoleOut = defineEdgeIndex(worksAt, { fields: ["role"], direction: "out", where: (w) => w.deletedAt.isNull(),});
// drizzle-kit will include these indexes in generated migrationsexport const typegraphTables = createPostgresTables({}, { indexes: [personEmail, worksAtRoleOut],});For SQLite, use createSqliteTables:
import { createSqliteTables } from "@nicia-ai/typegraph/adapters/drizzle/sqlite";
export const typegraphTables = createSqliteTables({}, { indexes: [personEmail, worksAtRoleOut],});Node Indexes
Section titled “Node Indexes”defineNodeIndex(nodeType, config) creates an index definition for node properties and, via
keySystemColumns, TypeGraph system columns.
Key options:
fields: JSON property paths used for filtering/ordering (B-tree expression keys). Optional ifcoveringFieldsorkeySystemColumnssupply the key instead — an index must declare at least one of the three.coveringFields: additional properties frequently selected with the same filters. These become additional index keys to enable index-only reads when combined with smart select.keySystemColumns: system columns (e.g."id") to include in the key, after thescopeprefix and beforefields. See Keying on system columns. Rejects"id"combined withunique: true— every node’sidis already unique per row, so a unique index keyed onidplus other columns can never enforce a meaningful constraint across those other columns.unique: create a unique index.scope: prefixes index keys with TypeGraph system columns (default is"graphAndKind").where: partial index predicate (portable DSL, compiled per dialect).
Nested JSON Paths
Section titled “Nested JSON Paths”For top-level properties, use the field name:
defineNodeIndex(Person, { fields: ["email"] });For nested properties inside props, use a JSON pointer:
defineNodeIndex(Person, { fields: ["/metadata/priority"] });You can also pass pointer segments:
defineNodeIndex(Person, { fields: [["metadata", "priority"] as const] });Index Scope
Section titled “Index Scope”Index scope controls which TypeGraph system columns are prefixed ahead of your JSON keys:
"graphAndKind"(default): prefixes with(graph_id, kind)to match most TypeGraph queries."graph": prefixes withgraph_idonly (rare; useful for cross-kind queries within a graph)."none": no system prefix (rare; usually only correct for global queries).
Keying on system columns (keySystemColumns)
Section titled “Keying on system columns (keySystemColumns)”fields and coveringFields only ever reference your schema’s own properties, and scope only
ever prefixes graph_id/kind — neither can put a system column like id into the index key.
Some query shapes need that. A reverse traversal — .traverse("hasCreator", { direction: "in" }).to("Post", "post") — compiles a join on the target node’s own id (n.id = e.from_id). If you
also filter, sort, or select a prop on that same node (say creationDate), a covering index needs
id in its key to match that join — graph_id/kind and creationDate alone aren’t enough,
because the index can’t be chosen for an id-equality join it doesn’t cover.
const postRecent = defineNodeIndex(Post, { keySystemColumns: ["id"], coveringFields: ["creationDate"],});// -> CREATE INDEX ... ON typegraph_nodes (graph_id, kind, id, (props #>> ARRAY['creationDate']))Validation:
- Node indexes only. Rejects edge-only system columns (
fromKind/fromId/toKind/toId). - Rejects a column already implied by
scope(e.g.graph_idwhenscope: "graphAndKind"). - Not supported with
method: "gin" | "trigram"(same restriction ascoveringFields).
Edge Indexes
Section titled “Edge Indexes”defineEdgeIndex(edgeType, config) works the same way as node indexes, with one extra option:
direction:"out" | "in" | "none"(default"none"). When set, the index keys are prefixed with the join key used by traversal queries (from_idfor"out",to_idfor"in").
This makes it easy to create indexes that match .traverse() patterns.
When to use direction:
"out": optimize outbound traversals that join onfrom_id(start node → edges)."in": optimize inbound traversals that join onto_id(end node → edges)."none": for edge queries not anchored by a traversal join key (less common).
Partial Indexes (WHERE)
Section titled “Partial Indexes (WHERE)”Use where to create partial indexes with a small, typed predicate DSL.
System columns are available (e.g. deletedAt, createdAt, fromId), as well as your schema
properties (e.g. email, role).
import { andWhere, defineNodeIndex } from "@nicia-ai/typegraph/indexes";
const activeEmail = defineNodeIndex(Person, { fields: ["email"], where: (w) => andWhere(w.deletedAt.isNull(), w.isActive.eq(true)),});Covering Indexes
Section titled “Covering Indexes”To maximize the benefit of smart select optimization, create indexes that include both the filter columns and selected columns. This enables index-only scans where the database satisfies the entire query from the index.
// Index covers email filter AND name selectionconst personEmailWithName = defineNodeIndex(Person, { fields: ["email"], coveringFields: ["name"], where: (w) => w.deletedAt.isNull(),});Generated PostgreSQL:
CREATE INDEX idx_person_email_name ON typegraph_nodes (graph_id, kind, ((props #>> ARRAY['email'])), ((props #>> ARRAY['name']))) WHERE deleted_at IS NULL;Generated SQLite:
CREATE INDEX idx_person_email_name ON typegraph_nodes (graph_id, kind, json_extract(props, '$.email'), json_extract(props, '$.name')) WHERE deleted_at IS NULL;Batched Index Lookup (bulkFindByIndex)
Section titled “Batched Index Lookup (bulkFindByIndex)”store.nodes.<Kind>.bulkFindByIndex(indexName, items, options?) takes many in-memory records and
returns the live nodes that share each record’s declared index key — batched candidate retrieval
for import reconciliation, dedup-candidate discovery, and joining incoming records against the graph
by a declared composite key.
const candidates = await store.nodes.Person.bulkFindByIndex("person_active_name", [ { props: { isActive: true, name: "Ana" } }, { props: { name: "Bo" } }, // missing isActive → matches stored null]);// readonly Node<Person>[][] — one bucket per input, ordered by node idSemantics:
- One bucket per input, in input order; empty input returns
[]. The index may be non-unique, so each bucket is a (possibly empty) array — this is candidate retrieval, not a uniqueness guarantee. For unique lookups preferbulkFindByConstraint(backed by the uniqueness side-table). - TypeGraph computes the lookup key from
index.fieldsonly (JSON-pointer extraction, reusing the index’s own extraction expressions). NeithercoveringFieldsnorkeySystemColumnsare part of the probe key. An index declared withoutfields(onlycoveringFieldsand/orkeySystemColumns) has nothing to probe by and throwsConfigurationError. - The index’s partial
whereis applied in SQL to stored rows only; probes carry index-field values, nothing else. Only the indexed fields are validated — full records are not required. - A missing/
undefinedindexed field matches storedNULL(null-safe equality). Live, non-soft-deleted nodes only. options.limitPerInputcaps each bucket (ordered by node id); unbounded by default — no silent truncation. A non-positive value throwsValidationError. An unknown index name throwsNodeIndexNotFoundError. A non-scalar probe value throwsValidationError. On backends that support SQL window functions the cap is applied in-database (ROW_NUMBER()); on backends without them (capabilities.windowFunctions: false) it degrades to an in-memory cap after fetching the matching ids — same result, but it transfers all matching ids for low-selectivity keys.- Key field types: string, number, and boolean keys are supported. Date-typed key fields are
not — they throw
ConfigurationError, because SQLite compares stored ISO text byte-wise while PostgreSQL comparestimestamptzinstants, so the same instant in different ISO forms would match on one backend but not the other. Use a string-encoded key, orstore.query(...).where(...)for date predicates.
The lookup is correct whether or not the physical index has been materialized; materialize it (see below) for the query planner to actually use it. Null-safe predicates may be less reliably index-accelerated than plain equality.
Choosing the Right Index Type
Section titled “Choosing the Right Index Type”TypeGraph’s defineNodeIndex / defineEdgeIndex generate B-tree expression indexes — the right
choice for scalar equality, range, and ordering queries. But JSON properties can also hold arrays
and objects, which need different index strategies.
| Data shape | Query pattern | Index type | TypeGraph utility? |
|---|---|---|---|
Scalar (string, number, boolean) |
eq(), gt(), in(), orderBy() |
B-tree expression | Yes — defineNodeIndex |
| Array of scalars | contains(), containsAll(), containsAny() |
GIN (PostgreSQL) | No — use raw SQL |
| Nested object | hasKey(), pathEquals(), pathContains() |
GIN or B-tree expression | Partially — B-tree on specific paths |
B-tree expression indexes (scalar properties)
Section titled “B-tree expression indexes (scalar properties)”Best for equality, range, sorting, and prefix matching on individual JSON fields. This is what
defineNodeIndex and defineEdgeIndex generate.
// Good for: .whereNode("p", (p) => p.email.eq("..."))defineNodeIndex(Person, { fields: ["email"] });
// Good for: .orderBy("p", "createdScore", "desc")defineNodeIndex(Person, { fields: ["createdScore"] });GIN indexes (array containment — PostgreSQL only)
Section titled “GIN indexes (array containment — PostgreSQL only)”TypeGraph compiles array predicates to PostgreSQL’s JSONB containment operator over the field’s
extraction expression — (props #> ARRAY['tags']) @> $1. Declare a containment index with
method: "gin" and TypeGraph emits the matching expression GIN (jsonb_path_ops):
const personTags = defineNodeIndex(Person, { fields: ["tags"], method: "gin",});// materialized via store.materializeIndexes():// CREATE INDEX ... USING GIN (("props" #> ARRAY['tags']) jsonb_path_ops);The index accelerates all containment predicates on that field:
// contains: does the tags array include "typescript"?.whereNode("p", (p) => p.tags.contains("typescript"))
// containsAll: does it include BOTH "typescript" AND "graphql"?.whereNode("p", (p) => p.tags.containsAll(["typescript", "graphql"]))
// containsAny: does it include "typescript" OR "graphql"?.whereNode("p", (p) => p.tags.containsAny(["typescript", "graphql"]))Trigram indexes (substring and case-insensitive matching — PostgreSQL only)
Section titled “Trigram indexes (substring and case-insensitive matching — PostgreSQL only)”contains / startsWith / endsWith / ilike on string fields compile to
ILIKE on PostgreSQL, which a B-tree can never serve for infix patterns.
Declare method: "trigram" and TypeGraph emits an expression GIN with
gin_trgm_ops (installing the pg_trgm extension on first
materialization):
const personName = defineNodeIndex(Person, { fields: ["name"], method: "trigram",});// CREATE INDEX ... USING GIN (("props" #>> ARRAY['name']) gin_trgm_ops);
.whereNode("p", (p) => p.name.contains("smith")) // served by the index.whereNode("p", (p) => p.name.ilike("%SMITH%")) // also servedOn SQLite these declarations are skipped — SQLite’s substring-search
story is FTS5 fulltext via searchable() fields.
GIN-family methods take exactly one field and don’t support unique,
coveringFields, or where; the query’s graph_id / kind filters apply
as residual conditions over the index’s candidate rows.
Combining B-tree and GIN
Section titled “Combining B-tree and GIN”For kinds where you filter on both scalar fields (equality, range) and array or substring predicates, declare both index types:
const personEmail = defineNodeIndex(Person, { fields: ["email"] });const personTags = defineNodeIndex(Person, { fields: ["tags"], method: "gin" });PostgreSQL’s query planner can use both indexes together via a BitmapAnd scan when a query filters on both a scalar field and an array field.
Generating SQL (No drizzle-kit)
Section titled “Generating SQL (No drizzle-kit)”If you manage migrations yourself, generate DDL snippets:
import { generateIndexDDL } from "@nicia-ai/typegraph/indexes";
const sql = generateIndexDDL(personEmail, "postgres");// → CREATE INDEX ...;Verifying Index Usage
Section titled “Verifying Index Usage”Use EXPLAIN ANALYZE to verify your indexes are being used:
-- PostgreSQLEXPLAIN ANALYZE SELECT props #>> ARRAY['email'], props #>> ARRAY['name']FROM typegraph_nodesWHERE graph_id = 'my_graph' AND kind = 'Person' AND deleted_at IS NULL AND (props #>> ARRAY['email']) = 'alice@example.com';
-- SQLiteEXPLAIN QUERY PLAN SELECT json_extract(props, '$.email'), json_extract(props, '$.name')FROM typegraph_nodesWHERE graph_id = 'my_graph' AND kind = 'Person' AND deleted_at IS NULL AND json_extract(props, '$.email') = 'alice@example.com';Look for “Index Scan” or “Index Only Scan” (PostgreSQL) or “USING INDEX” (SQLite) in the output.
Profiler Integration
Section titled “Profiler Integration”Pass your existing indexes to the Query Profiler so recommendations focus on what you don’t have:
import { QueryProfiler } from "@nicia-ai/typegraph/profiler";import { toDeclaredIndexes } from "@nicia-ai/typegraph/indexes";
const profiler = new QueryProfiler({ declaredIndexes: toDeclaredIndexes([personEmail, worksAtRoleOut]),});Limitations
Section titled “Limitations”- The default
defineNodeIndex/defineEdgeIndexmethod generates B-tree expression indexes for scalar properties (string,number,boolean,Date). Array containment and substring matching are served bymethod: "gin"andmethod: "trigram"declarations. - GIN-family methods are PostgreSQL-only;
materializeIndexes()reports them asskippedon SQLite, which has no equivalent for JSON containment acceleration (substring search on SQLite is served by FTS5 fulltext). - Embedding fields live in per-
(graphId, kind, field)vector tables (tg_vec_*) and are indexed throughstore.materializeIndexes()(pgvector builds an HNSW / IVFFlat ANN index; sqlite-vec and libSQL reportskipped/build their own). See Semantic Search.
System Indexes
Section titled “System Indexes”TypeGraph ships a set of system indexes on its own relations (nodes, edges, and the recorded
history tables) — the traversal, listing, temporal-validity, and bare-id access paths every
compiled query relies on. They are declared once (SYSTEM_INDEX_DECLARATIONS, exported for
inspection), and both dialects’ schemas derive from that single list, so SQLite and PostgreSQL
always carry the same set.
You normally never manage them: fresh databases get them at bootstrap, and
createStoreWithSchema() brings an already-initialized database up to the running library
version’s set on boot (with CREATE INDEX CONCURRENTLY on PostgreSQL). Deployments that boot
without createStoreWithSchema can run store.materializeSystemIndexes() once after a library
upgrade; it shares materializeIndexes()’s status tracking, drift signatures, and concurrent-build
claim protocol, and settles with no DDL when everything already exists.
Next Steps
Section titled “Next Steps”- Performance Overview — Best practices, N+1 prevention, batch patterns
- Query Profiler — Automatic index recommendations