Skip to content

Architecture

This page explains how TypeGraph works under the hood, the design decisions that shaped it, and why certain tradeoffs were made.

┌────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ TypeGraph Library │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Schema │ │ Query │ │ │
│ │ │ DSL │ │ Builder │ │ │
│ │ └──────┬─────┘ └─────┬──────┘ │ │
│ │ │ │ │ │
│ │ └──────────────┴───────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Ontology Layer │ │ │
│ │ └──────────────────┘ │ │
│ └─────────────────────────┬────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ TypeGraph Backend Port │ │
│ └────────────┬───────────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ SQL Adapter │ │
│ │ (Drizzle) │ │
│ └──────┬───────┘ │
│ │ │
└───────────────────────────┼────────────────────────────┘
┌─────────────────┐
│ Your Database │
└─────────────────┘

TypeGraph is an embedded library, not a database. It runs in your application process and compiles queries to SQL. A managed local Store can own its SQLite or PGlite connection; adapter integrations can instead use a connection your application already owns.

TypeGraph’s public types match its supported runtime surfaces. The default Store exposes graph operations; AdapterStore adds native transaction interoperability; transaction contexts expose a read-only backend projection. Every store exposes store.capabilities, the read-only runtime feature descriptor used by its backend, so portable code can branch on atomicity, vector, fulltext, or analytics support without reaching through the adapter boundary. Whenever a surface loses capabilities, TypeGraph constructs an explicit allowlist projection. Proxy overlays are reserved for decorating a surface without changing its capabilities.

The internal store and transaction ports are non-enumerable symbol properties and are absent from public TypeScript contracts. They are not a JavaScript security boundary: sufficiently reflective code can discover symbol properties, as it can inspect internals in any in-process library. The guarantee applies to all documented and supported access paths.

Decision: TypeGraph is a library dependency, not a separate service.

Why: Graph databases like Neo4j require managing another piece of infrastructure. For many use cases—knowledge bases, organizational structures, content relationships—the graph is part of your application, not a standalone system.

Tradeoff: You don’t get Neo4j’s broad graph-data-science suite (community detection, betweenness centrality, and similar specialized analytics), but you avoid:

  • Additional deployment complexity
  • Network latency between app and graph
  • Separate scaling and monitoring
  • Data synchronization challenges

Decision: Zod schemas are the single source of truth. TypeScript types are inferred, not duplicated.

Why: In many graph systems, you define types in one place, validation in another, and database schemas in a third. This leads to drift and bugs.

With TypeGraph:

const Person = defineNode("Person", {
schema: z.object({
name: z.string().min(1),
email: z.string().email().optional(),
}),
});
// TypeScript type is inferred automatically
type PersonProps = z.infer<typeof Person.schema>;
// { name: string; email?: string }

The schema drives:

  • Runtime validation on create/update
  • TypeScript types for compile-time safety
  • Database storage format
  • Query builder type constraints

Decision: Compile graph queries to SQL, don’t implement a custom query engine.

Why: SQLite and PostgreSQL are battle-tested, highly optimized query engines. Rather than building another one:

// Your query
store.query()
.from("Person", "p")
.traverse("worksAt", "e")
.to("Company", "c")
.select((ctx) => ({ person: ctx.p.name, company: ctx.c.name }))
// Compiles to SQL with CTEs
WITH person_cte AS (
SELECT * FROM typegraph_nodes WHERE kind = 'Person' AND deleted_at IS NULL
),
edge_cte AS (
SELECT * FROM typegraph_edges WHERE kind = 'worksAt' AND deleted_at IS NULL
),
company_cte AS (
SELECT * FROM typegraph_nodes WHERE kind = 'Company' AND deleted_at IS NULL
)
SELECT
p.props->>'name' as person,
c.props->>'name' as company
FROM person_cte p
JOIN edge_cte e ON e.from_id = p.id
JOIN company_cte c ON c.id = e.to_id

This means:

  • You get database-level query optimization
  • Indexes work as expected
  • Transactions are ACID
  • You can analyze queries with EXPLAIN

Decision: Compute transitive closures at store initialization, not query time.

Why: Semantic relationships like subClassOf and implies form hierarchies. Computing “all subclasses of Media” during every query would be expensive.

Instead, when you create a store:

const store = createStore(graph, backend);
// ↑ Computes:
// - subClassOf closure: Media → [Media, Podcast, Article, Video]
// - implies closure: marriedTo → [marriedTo, partneredWith, knows]
// - disjoint sets: Person ⊥ Organization ⊥ Product

These closures are stored in the TypeRegistry and used during query compilation:

.from("Media", "m", { includeSubClasses: true })
// At compile time, expands to: WHERE kind IN ('Media', 'Podcast', 'Article', 'Video')

Tradeoff: Changing the ontology requires recreating the store. But ontologies typically change rarely compared to instance data.

Decision: Store the graph schema and ontology as data in the database itself.

Why: Most ORMs and graph libraries define schemas only in application code. The database stores data but has no record of what the data means. This creates problems:

  • You can’t understand the database without reading the application source
  • Schema changes are invisible—no history, no diff, no audit trail
  • Exports require the application to interpret the data
  • Multiple applications can’t share schema understanding

TypeGraph takes a different approach: the schema is data. When you initialize a store, the complete schema (node types, edge types, property definitions, ontology relations, precomputed closures) is serialized to JSON and stored in typegraph_schema_versions:

SELECT schema_doc FROM typegraph_schema_versions
WHERE graph_id = 'my_graph' AND is_active = TRUE;

The stored schema includes everything needed to understand the graph:

{
graphId: "my_graph",
version: 3,
nodes: {
Person: { properties: { /* JSON Schema */ }, ... },
Company: { ... }
},
edges: {
worksAt: { fromKinds: ["Person"], toKinds: ["Company"], ... }
},
ontology: {
relations: [{ metaEdge: "subClassOf", from: "Engineer", to: "Person" }],
closures: {
subClassAncestors: { Engineer: ["Person"] },
// ... precomputed inference data
}
}
}

This enables:

Capability How It Works
Self-describing database Query the schema without application code—useful for debugging, admin tools, and data exploration
Schema versioning Every schema change creates a new version; previous versions are preserved for auditing
Change detection Compare stored schema to code schema to detect additions, removals, and breaking changes
Portable exports The interchange format is self-contained—importers know what the data means
Runtime introspection Applications can query the schema at runtime for dynamic UI, validation, or documentation
import { getActiveSchema, getSchemaChanges } from "@nicia-ai/typegraph/schema";
// Query the active schema at runtime
const schema = await getActiveSchema(backend, "my_graph");
console.log("Node types:", Object.keys(schema.nodes));
console.log("Edge types:", Object.keys(schema.edges));
// Detect pending changes before deployment
const diff = await getSchemaChanges(backend, graph);
if (!diff.isBackwardsCompatible) {
console.error("Breaking changes require migration");
}

Tradeoff: Schema storage adds a small amount of database overhead (one JSON document per version). The benefit is a database that explains itself.

TypeGraph uses two core tables:

-- Nodes table
CREATE TABLE typegraph_nodes (
graph_id TEXT NOT NULL,
kind TEXT NOT NULL,
id TEXT NOT NULL,
props JSON NOT NULL, -- Properties as JSON
version INTEGER NOT NULL, -- Optimistic concurrency
valid_from TEXT NOT NULL, -- Temporal validity
valid_to TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT, -- Soft delete
PRIMARY KEY (graph_id, kind, id, valid_from)
);
-- Edges table
CREATE TABLE typegraph_edges (
graph_id TEXT NOT NULL,
kind TEXT NOT NULL,
id TEXT NOT NULL,
from_kind TEXT NOT NULL,
from_id TEXT NOT NULL,
to_kind TEXT NOT NULL,
to_id TEXT NOT NULL,
props JSON NOT NULL,
version INTEGER NOT NULL,
valid_from TEXT NOT NULL,
valid_to TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
PRIMARY KEY (graph_id, kind, id, valid_from)
);

Decision: Store node/edge properties as JSON, not as columns.

Why:

  1. Schema flexibility: Adding a property doesn’t require ALTER TABLE
  2. Heterogeneous nodes: Different node kinds have different schemas
  3. Query simplicity: One table for all nodes, not one per kind

Both SQLite (JSON1 extension) and PostgreSQL (JSONB) have efficient JSON operators:

-- PostgreSQL
SELECT props->>'name' FROM typegraph_nodes WHERE props->>'status' = 'active';
-- SQLite
SELECT json_extract(props, '$.name') FROM typegraph_nodes WHERE json_extract(props, '$.status') = 'active';

Tradeoff: You can’t create a B-tree index on a JSON property as easily as a column. For high-cardinality filtering, consider:

  • PostgreSQL: Expression indexes on JSONB paths
  • SQLite: Expression indexes on json_extract(...) (or generated columns)

See Indexes for TypeGraph utilities to define and create these indexes.

Every node and edge tracks temporal validity:

┌──────────────────────────────────────────────────────────────┐
│ Node: Article#123 │
├──────────────────────────────────────────────────────────────┤
│ Version 1: "Draft" │ valid_from: 2024-01-01 │
│ │ valid_to: 2024-01-15 │
├─────────────────────────┼────────────────────────────────────┤
│ Version 2: "Published" │ valid_from: 2024-01-15 │
│ │ valid_to: NULL (current) │
└─────────────────────────┴────────────────────────────────────┘

When you update a node:

  1. The current row’s valid_to is set to now
  2. A new row is inserted with valid_from = now, valid_to = NULL

This enables:

  • Point-in-time queries: “What did the graph look like on January 10th?”
  • Audit trails: “What were all the versions of this article?”
  • Soft deletes: deleted_at marks deletion without losing history
Query Builder → Query AST → TypeGraph SQL Fragment → Adapter → Database
  1. Query Builder: Fluent API that constructs a typed AST
  2. Query AST: A data structure representing the query (nodes, edges, predicates, projections)
  3. SQL Generator: Transforms the AST into TypeGraph’s immutable, database-independent SQL fragment representation
  4. Adapter: Renders the fragment for SQLite or PostgreSQL and executes it through the configured driver

TypeGraph compiles traversals to CTEs, which databases optimize well:

store
.query()
.from("Person", "p")
.traverse("authored", "e")
.to("Document", "d")
.whereNode("d", (d) => d.status.eq("published"));

Becomes:

WITH
step_0 AS (
-- Start: all Person nodes
SELECT * FROM typegraph_nodes
WHERE graph_id = $1 AND kind = 'Person' AND deleted_at IS NULL
),
step_1 AS (
-- Traverse: follow 'authored' edges
SELECT e.*, s.id as _from_step
FROM typegraph_edges e
JOIN step_0 s ON e.from_id = s.id
WHERE e.kind = 'authored' AND e.deleted_at IS NULL
),
step_2 AS (
-- Arrive: at Document nodes
SELECT n.*, s.id as _edge_id
FROM typegraph_nodes n
JOIN step_1 s ON n.id = s.to_id
WHERE n.kind = 'Document' AND n.deleted_at IS NULL
)
SELECT
step_0.props->>'name' as person,
step_2.props->>'title' as document
FROM step_0
JOIN step_1 ON step_1._from_step = step_0.id
JOIN step_2 ON step_2._edge_id = step_1.id
WHERE step_2.props->>'status' = 'published';

For recursive() traversals with cycle prevention enabled (the default), TypeGraph generates recursive CTEs like:

WITH RECURSIVE path AS (
-- Base case: starting nodes
SELECT id, 1 as depth, ARRAY[id] as path
FROM typegraph_nodes
WHERE kind = 'Person' AND id = $1
UNION ALL
-- Recursive case: follow edges
SELECT n.id, p.depth + 1, p.path || n.id
FROM path p
JOIN typegraph_edges e ON e.from_id = p.id
JOIN typegraph_nodes n ON n.id = e.to_id
WHERE e.kind = 'reportsTo'
AND p.depth < 10 -- Implicit cap for unbounded traversal
AND NOT n.id = ANY(p.path) -- Cycle detection
)
SELECT * FROM path;

When you opt into cyclePolicy: "allow" and do not project a path column, TypeGraph can use a lighter recursive shape without path-array state and cycle predicates.

Semantic search with embeddings works across all backends — pgvector on PostgreSQL, sqlite-vec on better-sqlite3, and libSQL/Turso’s built-in vector engine. The behavior is selected by a pluggable VectorStrategy, so adding a new backend is a single strategy object with no edits to the core.

Each backend wires a strategy that knows how to store embeddings and compile similarity queries:

Backend Strategy Storage / Index Metrics
PostgreSQL pgvectorStrategy (default) typed vector(N) tables, HNSW / IVFFlat cosine, l2, inner_product
better-sqlite3 sqliteVecStrategy (when the sqlite-vec extension loads) vec0 virtual tables (KNN) cosine, l2
libSQL / Turso libsqlVectorStrategy (wired automatically) F32_BLOB(N), DiskANN ANN via libsql_vector_idx + vector_top_k cosine, l2

createSqliteBackend and createPostgresBackend accept a vector?: VectorStrategy option to override the default. The strategies, buildVectorCapabilities, and the complete VectorStrategy / VectorSlot authoring vocabulary are exported from @nicia-ai/typegraph/backend.

A backend advertises its vector support as data on backend.capabilities.vector:

backend.capabilities.vector; // { supported, metrics, indexTypes, maxDimensions, ... }

Embeddings are stored in per-field typed tables, one per (graphId, nodeKind, fieldPath), each carrying that field’s fixed dimension. Tables are provisioned by the privileged migrator (createStoreWithSchema, and evolve() for runtime-added fields), with a durable contribution marker; the runtime hot path asserts the marker and never issues DDL. They are named tg_vec_<graphId>_<kind>_<field>. Graph-scoping the table name lets multiple graphs in one database declare the same kind+field at different dimensions without collision.

-- PostgreSQL with pgvector: one table per (graphId, kind, field). The kind
-- and field are encoded in the table name, so rows only key by node.
CREATE TABLE tg_vec_my_graph_document_embedding (
graph_id TEXT NOT NULL,
node_id TEXT NOT NULL,
embedding vector(1536) NOT NULL, -- pgvector type, fixed dimension per field
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (graph_id, node_id)
);
CREATE INDEX ON tg_vec_my_graph_document_embedding
USING hnsw (embedding vector_cosine_ops); -- HNSW index for fast similarity

generatePostgresMigrationSQL() runs CREATE EXTENSION IF NOT EXISTS vector but creates no embedding table — the per-field tables are provisioned by createStoreWithSchema at boot (under the privileged role).

The query API is storage-transparent and unchanged across backends:

.whereNode("d", (d) => d.embedding.similarTo(queryVector, 10))

Compiles to a backend-specific nearest-neighbor query — for example, on PostgreSQL:

SELECT * FROM typegraph_nodes n
JOIN tg_vec_my_graph_document_embedding e
ON e.node_id = n.id AND e.graph_id = n.graph_id
ORDER BY e.embedding <=> $1 -- Cosine distance
LIMIT 10;

The backend’s vector index (pgvector HNSW/IVFFlat, sqlite-vec vec0, or libSQL DiskANN) handles approximate nearest neighbor search efficiently.

  • Point lookups by ID: O(1) with primary key index
  • Traversal frontiers: Set-based SQL rounds with database-managed joins and de-duplication
  • Ontology expansion: Precomputed at initialization, O(1) at query time
  • Semantic search: ANN indexes (pgvector HNSW/IVFFlat, sqlite-vec vec0, libSQL DiskANN) provide sub-linear search
  • Deep recursive traversals: Recursive CTEs are more expensive than simple JOINs
  • Whole-graph algorithms: WCC, label propagation, and PageRank iterate over every visible node by default, or over an explicit nodeKinds induced subgraph, and their selected edges
  • Large property filtering without indexes: JSON extraction is slower than column access
  • Cross-kind queries: includeSubClasses: true increases the WHERE IN set
  1. Filter early: Apply predicates as close to the source as possible
  2. Limit results: Always paginate large result sets
  3. Use specific kinds: Avoid includeSubClasses unless needed
  4. Index JSON paths: For frequently-filtered properties, add expression indexes
  5. Batch writes: Use transactions to reduce disk syncs and round-trips

Native graph databases (Neo4j, Amazon Neptune) excel at:

  • Very deep traversals (10+ hops)
  • Broad graph-data-science suites beyond the focused built-in algorithms
  • Massive scale (billions of nodes)

TypeGraph is designed for:

  • Knowledge bases with thousands to millions of nodes
  • Shallow to medium traversals (1-5 hops typically)
  • Applications that already use SQL databases
  • Teams that want one database to manage

The schema DSL, Store, query compiler, and SQL fragments belong to TypeGraph. They do not import a database adapter’s types. This keeps the public API stable and prevents consumers from typechecking declarations for drivers and dialects they never use.

Drizzle remains an implementation detail of the built-in SQLite and PostgreSQL adapters:

  1. Driver integration: Reuses mature SQLite and PostgreSQL connections
  2. Adapter-native access: Bring-your-own-connection entrypoints retain precise Drizzle database and transaction types
  3. Replaceable boundary: The core depends on TypeGraph ports; adapters translate fragments and operations at the edge

The package exports that boundary directly. Schema-only packages can import the graph DSL and its schema-derived types from the Drizzle-free @nicia-ai/typegraph/core entrypoint. Backend and search-strategy authors can import the full Drizzle-free port vocabulary, including GraphBackend, AdapterBackend, DialectAdapter, and SqlFragment, from @nicia-ai/typegraph/backend.

  1. Runtime validation: Not just types, but actual validation
  2. Inference: z.infer<T> eliminates type duplication
  3. Composition: Build complex schemas from simple ones
  4. Ecosystem: Widely used, lots of integrations