Architecture
This page explains how TypeGraph works under the hood, the design decisions that shaped it, and why certain tradeoffs were made.
High-Level Architecture
Section titled “High-Level Architecture”┌────────────────────────────────────────────────────────┐│ 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.
Capability boundaries
Section titled “Capability boundaries”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.
Core Design Principles
Section titled “Core Design Principles”1. Embedded, Not External
Section titled “1. Embedded, Not External”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
2. Schema-First, Type-Driven
Section titled “2. Schema-First, Type-Driven”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 automaticallytype 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
3. SQL as the Execution Engine
Section titled “3. SQL as the Execution Engine”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 querystore.query() .from("Person", "p") .traverse("worksAt", "e") .to("Company", "c") .select((ctx) => ({ person: ctx.p.name, company: ctx.c.name }))
// Compiles to SQL with CTEsWITH 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 companyFROM person_cte pJOIN edge_cte e ON e.from_id = p.idJOIN company_cte c ON c.id = e.to_idThis means:
- You get database-level query optimization
- Indexes work as expected
- Transactions are ACID
- You can analyze queries with EXPLAIN
4. Precomputed Ontology
Section titled “4. Precomputed Ontology”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 ⊥ ProductThese 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.
5. Homoiconic Schema Storage
Section titled “5. Homoiconic Schema Storage”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_versionsWHERE 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 runtimeconst 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 deploymentconst 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.
Data Model
Section titled “Data Model”Storage Schema
Section titled “Storage Schema”TypeGraph uses two core tables:
-- Nodes tableCREATE 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 tableCREATE 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));Why JSON for Properties?
Section titled “Why JSON for Properties?”Decision: Store node/edge properties as JSON, not as columns.
Why:
- Schema flexibility: Adding a property doesn’t require ALTER TABLE
- Heterogeneous nodes: Different node kinds have different schemas
- Query simplicity: One table for all nodes, not one per kind
Both SQLite (JSON1 extension) and PostgreSQL (JSONB) have efficient JSON operators:
-- PostgreSQLSELECT props->>'name' FROM typegraph_nodes WHERE props->>'status' = 'active';
-- SQLiteSELECT 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.
Temporal Model
Section titled “Temporal Model”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:
- The current row’s
valid_tois set to now - 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_atmarks deletion without losing history
Query Compilation
Section titled “Query Compilation”The Query Pipeline
Section titled “The Query Pipeline”Query Builder → Query AST → TypeGraph SQL Fragment → Adapter → Database- Query Builder: Fluent API that constructs a typed AST
- Query AST: A data structure representing the query (nodes, edges, predicates, projections)
- SQL Generator: Transforms the AST into TypeGraph’s immutable, database-independent SQL fragment representation
- Adapter: Renders the fragment for SQLite or PostgreSQL and executes it through the configured driver
Common Table Expressions (CTEs)
Section titled “Common Table Expressions (CTEs)”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 documentFROM step_0JOIN step_1 ON step_1._from_step = step_0.idJOIN step_2 ON step_2._edge_id = step_1.idWHERE step_2.props->>'status' = 'published';Recursive CTEs for Variable-Length Paths
Section titled “Recursive CTEs for Variable-Length Paths”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.
Vector Search Architecture
Section titled “Vector Search Architecture”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.
Pluggable Strategies
Section titled “Pluggable Strategies”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, ... }Storage
Section titled “Storage”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 similaritygeneratePostgresMigrationSQL() 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).
Query Flow
Section titled “Query Flow”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 nJOIN tg_vec_my_graph_document_embedding e ON e.node_id = n.id AND e.graph_id = n.graph_idORDER BY e.embedding <=> $1 -- Cosine distanceLIMIT 10;The backend’s vector index (pgvector HNSW/IVFFlat, sqlite-vec vec0, or libSQL DiskANN) handles approximate
nearest neighbor search efficiently.
Performance Characteristics
Section titled “Performance Characteristics”What’s Fast
Section titled “What’s Fast”- 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
What’s Slower
Section titled “What’s Slower”- 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
nodeKindsinduced subgraph, and their selected edges - Large property filtering without indexes: JSON extraction is slower than column access
- Cross-kind queries:
includeSubClasses: trueincreases the WHERE IN set
Optimization Strategies
Section titled “Optimization Strategies”- Filter early: Apply predicates as close to the source as possible
- Limit results: Always paginate large result sets
- Use specific kinds: Avoid
includeSubClassesunless needed - Index JSON paths: For frequently-filtered properties, add expression indexes
- Batch writes: Use transactions to reduce disk syncs and round-trips
Why These Tradeoffs?
Section titled “Why These Tradeoffs?”Why Not a Native Graph Database?
Section titled “Why Not a Native Graph Database?”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
Why a TypeGraph-Owned Backend Port?
Section titled “Why a TypeGraph-Owned Backend Port?”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:
- Driver integration: Reuses mature SQLite and PostgreSQL connections
- Adapter-native access: Bring-your-own-connection entrypoints retain precise Drizzle database and transaction types
- 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.
Why Zod for Schemas?
Section titled “Why Zod for Schemas?”- Runtime validation: Not just types, but actual validation
- Inference:
z.infer<T>eliminates type duplication - Composition: Build complex schemas from simple ones
- Ecosystem: Widely used, lots of integrations
Next Steps
Section titled “Next Steps”- Performance - Benchmarks and optimization tips
- Schemas & Stores - Complete function signatures
- Integration Patterns - How to integrate with your stack