Backend Setup
TypeGraph stores graph data in your existing relational database using Drizzle ORM adapters. This guide covers setting up SQLite and PostgreSQL backends.
SQLite
Section titled “SQLite”SQLite is ideal for development, testing, single-server deployments, and embedded applications.
Quick Setup
Section titled “Quick Setup”For development and testing, use the convenience function that handles everything:
import { createLocalSqliteBackend } from "@nicia-ai/typegraph/sqlite/local";import { createStore } from "@nicia-ai/typegraph";
// In-memory database (resets on restart)const { backend } = createLocalSqliteBackend();const store = createStore(graph, backend);
// File-based database (persisted)const { backend, db } = createLocalSqliteBackend({ path: "./app.db" });const store = createStore(graph, backend);Manual Setup
Section titled “Manual Setup”For full control over the database connection:
import Database from "better-sqlite3";import { drizzle } from "drizzle-orm/better-sqlite3";import { createSqliteBackend, generateSqliteMigrationSQL } from "@nicia-ai/typegraph/sqlite";import { createStoreWithSchema } from "@nicia-ai/typegraph";
// Create and configure the databaseconst sqlite = new Database("app.db");sqlite.pragma("journal_mode = WAL"); // Recommended for performancesqlite.pragma("foreign_keys = ON");
// Create Drizzle instance and backendconst db = drizzle(sqlite);const backend = createSqliteBackend(db);
// createStoreWithSchema auto-creates tables on first runconst [store] = await createStoreWithSchema(graph, backend);
// Clean up when doneprocess.on("exit", () => sqlite.close());If you need to run DDL yourself (e.g. via a migration tool), use
generateSqliteMigrationSQL() with createStore() instead:
sqlite.exec(generateSqliteMigrationSQL());const store = createStore(graph, backend);SQLite with Vector Search
Section titled “SQLite with Vector Search”For semantic search, use sqlite-vec:
import Database from "better-sqlite3";import { drizzle } from "drizzle-orm/better-sqlite3";import { createSqliteBackend, generateSqliteMigrationSQL } from "@nicia-ai/typegraph/sqlite";
const sqlite = new Database("app.db");
// Load sqlite-vec extensionsqlite.loadExtension("vec0");
// Run migrations (includes vector index setup)sqlite.exec(generateSqliteMigrationSQL());
const db = drizzle(sqlite);const backend = createSqliteBackend(db);See Semantic Search for query examples.
libsql / Turso
Section titled “libsql / Turso”For edge deployments, shared-driver setups, or Turso cloud databases, use the first-class libsql backend:
npm install @libsql/clientimport { createClient } from "@libsql/client";import { createLibsqlBackend } from "@nicia-ai/typegraph/sqlite/libsql";import { createStore } from "@nicia-ai/typegraph";
// Local fileconst client = createClient({ url: "file:app.db" });
// Or remote Turso database// const client = createClient({ url: "libsql://my-db.turso.io", authToken: "..." });
const { backend, db } = await createLibsqlBackend(client);const store = createStore(graph, backend);createLibsqlBackend handles DDL execution and configures the correct async
execution profile automatically. It returns both the backend and the underlying
Drizzle db instance for direct SQL access. The caller retains ownership of the
client and is responsible for closing it when done — this allows sharing a single
client across TypeGraph and other libraries.
API Reference
Section titled “API Reference”createLocalSqliteBackend(options?)
Section titled “createLocalSqliteBackend(options?)”Creates a SQLite backend with automatic database and schema setup.
function createLocalSqliteBackend(options?: { path?: string; // Database path, defaults to ":memory:" tables?: SqliteTables;}): { backend: GraphBackend; db: BetterSQLite3Database };createSqliteBackend(db, options?)
Section titled “createSqliteBackend(db, options?)”Creates a SQLite backend from an existing Drizzle database instance.
function createSqliteBackend(db: BetterSQLite3Database, options?: { tables?: SqliteTables }): GraphBackend;generateSqliteMigrationSQL()
Section titled “generateSqliteMigrationSQL()”Returns SQL for creating TypeGraph tables in SQLite.
function generateSqliteMigrationSQL(): string;createLibsqlBackend(client, options?)
Section titled “createLibsqlBackend(client, options?)”Creates a SQLite backend from a @libsql/client instance. Runs DDL automatically.
The caller retains ownership of the client and is responsible for closing it.
async function createLibsqlBackend(client: Client, options?: { tables?: SqliteTables }): Promise<{ backend: GraphBackend; db: LibSQLDatabase }>;PostgreSQL
Section titled “PostgreSQL”PostgreSQL is recommended for production deployments with concurrent access, large datasets, or when you need advanced features like pgvector.
createPostgresBackend is driver-agnostic. Pick the Drizzle adapter that matches your
runtime, and TypeGraph works the same way against each.
Choosing a PostgreSQL driver
Section titled “Choosing a PostgreSQL driver”| Runtime | Recommended driver | Drizzle adapter |
|---|---|---|
| Long-lived Node server (Fly, Render, Cloud Run, containers) | pg (node-postgres) or postgres (postgres-js) | drizzle-orm/node-postgres or drizzle-orm/postgres-js |
| Node serverless (Vercel Functions, AWS Lambda, Netlify Functions) | postgres (postgres-js) — faster cold start, lower per-query overhead | drizzle-orm/postgres-js |
| Bun server | postgres (postgres-js) or Bun’s built-in SQL | drizzle-orm/postgres-js or drizzle-orm/bun-sql |
| Edge runtime (Cloudflare Workers, Vercel Edge, Netlify Edge) — needs transactions | @neondatabase/serverless Pool over WebSockets | drizzle-orm/neon-serverless |
| Edge runtime — single-statement reads/writes only | @neondatabase/serverless neon(url) over HTTP | drizzle-orm/neon-http |
| Cloudflare Hyperdrive | pg or postgres (through the Hyperdrive pooler) | drizzle-orm/node-postgres or drizzle-orm/postgres-js |
node-postgres (pg)
Section titled “node-postgres (pg)”The default choice for long-lived Node servers. Widest ecosystem and most deployment documentation.
import { Pool } from "pg";import { drizzle } from "drizzle-orm/node-postgres";import { createPostgresBackend } from "@nicia-ai/typegraph/postgres";import { createStoreWithSchema } from "@nicia-ai/typegraph";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20,});
const db = drizzle(pool);const backend = createPostgresBackend(db);const [store] = await createStoreWithSchema(graph, backend);If you manage DDL externally, use generatePostgresMigrationSQL() with createStore():
import { generatePostgresMigrationSQL } from "@nicia-ai/typegraph/postgres";
await pool.query(generatePostgresMigrationSQL());const store = createStore(graph, backend);postgres-js
Section titled “postgres-js”A leaner Postgres client with lower per-query overhead and smaller bundle size. Good default for Node serverless platforms and Bun. Fully tested against TypeGraph’s adapter and integration suites.
npm install postgres drizzle-ormimport postgres from "postgres";import { drizzle } from "drizzle-orm/postgres-js";import { createPostgresBackend } from "@nicia-ai/typegraph/postgres";import { createStoreWithSchema } from "@nicia-ai/typegraph";
const sql = postgres(process.env.DATABASE_URL, { max: 10, idle_timeout: 30,});
const db = drizzle(sql);const backend = createPostgresBackend(db);const [store] = await createStoreWithSchema(graph, backend);Transactions go through sql.begin(fn); TypeGraph handles this automatically via
Drizzle’s db.transaction(). Isolation levels are honored the same way as with
node-postgres.
Neon serverless (WebSockets)
Section titled “Neon serverless (WebSockets)”For edge runtimes like Cloudflare Workers, Vercel Edge, and Netlify Edge — anywhere
native TCP sockets aren’t available. Neon’s @neondatabase/serverless driver speaks
the Postgres wire protocol over WebSockets and exposes a pg-Pool-compatible API.
npm install @neondatabase/serverless drizzle-ormimport { Pool } from "@neondatabase/serverless";import { drizzle } from "drizzle-orm/neon-serverless";import { createPostgresBackend } from "@nicia-ai/typegraph/postgres";import { createStoreWithSchema } from "@nicia-ai/typegraph";
const pool = new Pool({ connectionString: env.NEON_DATABASE_URL });const db = drizzle(pool);const backend = createPostgresBackend(db);const [store] = await createStoreWithSchema(graph, backend);When running under Node.js (for local testing), install ws and configure it once
before connecting:
import { neonConfig } from "@neondatabase/serverless";import ws from "ws";
neonConfig.webSocketConstructor = ws;Edge runtimes expose WebSocket globally and need no extra setup.
Neon HTTP
Section titled “Neon HTTP”For stateless edge workloads where you don’t need transactional writes. The HTTP
driver issues one request per query — lowest cold-start cost, no session lifecycle
to manage. TypeGraph auto-detects this driver and sets capabilities.transactions
to false, so store.transaction(...) falls through to sequential execution
rather than throwing.
Schema commits are the one exception: commitSchemaVersion and
setActiveVersion require atomicity to eliminate the orphan-row crash window
they exist to fix, so they refuse with a typed ConfigurationError on
non-transactional backends. Run schema migrations from a process with a
transactional driver (drizzle-orm/neon-serverless, regular pg, etc.); the
edge worker can keep using neon-http for reads and writes once the schema is
established.
npm install @neondatabase/serverless drizzle-ormimport { neon } from "@neondatabase/serverless";import { drizzle } from "drizzle-orm/neon-http";import { createPostgresBackend } from "@nicia-ai/typegraph/postgres";import { createStore } from "@nicia-ai/typegraph";
const sql = neon(env.NEON_DATABASE_URL);const db = drizzle({ client: sql });const backend = createPostgresBackend(db);const store = createStore(graph, backend);// backend.capabilities.transactions === false (auto-detected)Use neon-http for reads, single upserts, and migrations. Use neon-serverless
when you need atomic multi-statement writes.
PostgreSQL with Vector Search
Section titled “PostgreSQL with Vector Search”For semantic search, enable pgvector:
import { Pool } from "pg";import { drizzle } from "drizzle-orm/node-postgres";import { createPostgresBackend, generatePostgresMigrationSQL } from "@nicia-ai/typegraph/postgres";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Migration SQL includes pgvector extensionawait pool.query(generatePostgresMigrationSQL());// Runs: CREATE EXTENSION IF NOT EXISTS vector;
const db = drizzle(pool);const backend = createPostgresBackend(db);See Semantic Search for query examples.
Refreshing planner statistics after bulk loads
Section titled “Refreshing planner statistics after bulk loads”Call store.refreshStatistics() after any large initial import or bulk
backfill. PostgreSQL’s query planner relies on table statistics to choose
between multi-column indexes on typegraph_edges (forward vs reverse vs
cardinality), and when those statistics are stale the planner can pick a
reverse-index scan with a filter — turning a 0.5ms forward traversal into a
5ms one. SQLite’s planner is similarly sensitive: without sqlite_stat1
data, some FTS5 fulltext queries fall back to a plan that’s roughly 30×
slower. Autovacuum / background statistics collection will catch up
eventually, but refreshing explicitly gives correct latencies immediately.
for (const batch of batches) { await store.nodes.Document.bulkCreate(batch);}await store.refreshStatistics();The implementation runs ANALYZE against the TypeGraph-managed tables in
the configured backend — the call is safe regardless of custom table names
or fulltext / embedding configuration. If you need to bypass the API for an
unusual deployment (for example issuing ANALYZE over a separate admin
connection), call backend.execute() with raw SQL as the escape hatch.
pgbouncer / transaction-pool mode
Section titled “pgbouncer / transaction-pool mode”By default, the node-postgres / neon-serverless fast path issues server-side
prepared statements (client.query({name, text, values})) so PostgreSQL
caches the parsed plan per session. This is incompatible with pgbouncer in
transaction-pool mode: pgbouncer routes successive statements over different
backend connections, so a name registered on one connection isn’t visible
on the next. Pass prepareStatements: false to fall back to unnamed
positional queries:
const backend = createPostgresBackend(db, { prepareStatements: false, // pgbouncer transaction-pool compatibility});The cache that maps SQL text → statement name is LRU-bounded (default 256
entries, override via preparedStatementCacheMax). Worst-case server-side
footprint is roughly cap × pool size prepared statements across all pooled
connections.
Connection Pooling
Section titled “Connection Pooling”For production, always use connection pooling:
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20, // Maximum pool size idleTimeoutMillis: 30000, // Close idle connections after 30s connectionTimeoutMillis: 2000, // Timeout for new connections});
// Handle pool errorspool.on("error", (err) => { console.error("Unexpected pool error", err);});
// Graceful shutdownprocess.on("SIGTERM", async () => { await pool.end(); process.exit(0);});API Reference
Section titled “API Reference”createPostgresBackend(db, options?)
Section titled “createPostgresBackend(db, options?)”Creates a PostgreSQL backend adapter. Accepts any Drizzle PostgreSQL database
instance, regardless of the underlying driver. Tested with drizzle-orm/node-postgres,
drizzle-orm/postgres-js, drizzle-orm/neon-serverless, and
drizzle-orm/neon-http. The neon-http driver is auto-detected and
capabilities.transactions is set to false (HTTP can’t hold a session); use
drizzle-orm/neon-serverless if you need transactional writes.
function createPostgresBackend( db: AnyPgDatabase, options?: { tables?: PostgresTables; fulltext?: FulltextStrategy; /** * Override specific backend capabilities. Useful for HTTP-style * drivers or test scenarios. neon-http already has `transactions: * false` auto-applied — pass this to override that or to disable * other capabilities for custom drivers. */ capabilities?: Partial<BackendCapabilities>; /** * Use server-side prepared statements on the node-postgres / * neon-serverless fast path. Default `true`. Set to `false` when * pooling through pgbouncer in transaction-pool mode (named * statements are invisible across pooled connections). */ prepareStatements?: boolean; /** * LRU cap on the number of distinct SQL strings tracked for * prepared-statement naming. Default 256. Worst-case server-side * footprint is roughly `cap × pool size` prepared statements. * Ignored when `prepareStatements` is `false`. */ preparedStatementCacheMax?: number; },): GraphBackend;generatePostgresMigrationSQL()
Section titled “generatePostgresMigrationSQL()”Returns SQL for creating TypeGraph tables in PostgreSQL, including the pgvector extension.
function generatePostgresMigrationSQL(): string;generatePostgresDDL(tables?)
Section titled “generatePostgresDDL(tables?)”Returns individual DDL statements (CREATE TABLE, CREATE INDEX) as an array. Useful when you need per-statement control, for example to execute them in separate transactions or log them individually.
function generatePostgresDDL(tables?: PostgresTables): string[];Drizzle Entrypoints
Section titled “Drizzle Entrypoints”TypeGraph exposes Drizzle adapters through public entrypoints:
@nicia-ai/typegraph/sqlite— Generic SQLite adapter (any Drizzle SQLite driver)@nicia-ai/typegraph/sqlite/local— Batteries-included better-sqlite3 wrapper (Node.js only)@nicia-ai/typegraph/sqlite/libsql— Batteries-included libsql wrapper (Node.js, Workers, browser)@nicia-ai/typegraph/postgres— PostgreSQL adapter
Import from the entrypoint matching your database:
import { createSqliteBackend, tables } from "@nicia-ai/typegraph/sqlite";import { createLocalSqliteBackend } from "@nicia-ai/typegraph/sqlite/local";import { createLibsqlBackend } from "@nicia-ai/typegraph/sqlite/libsql";import { createPostgresBackend, tables } from "@nicia-ai/typegraph/postgres";Cloudflare D1
Section titled “Cloudflare D1”TypeGraph supports Cloudflare D1 for edge deployments, with some limitations.
Use createStoreWithSchema() to automatically create tables on a fresh D1
database and manage schema versions across deployments:
import { drizzle } from "drizzle-orm/d1";import { createStoreWithSchema } from "@nicia-ai/typegraph";import { createSqliteBackend } from "@nicia-ai/typegraph/sqlite";
export default { async fetch(request: Request, env: Env) { const db = drizzle(env.DB); const backend = createSqliteBackend(db); const [store] = await createStoreWithSchema(graph, backend);
// Use store... },};If you prefer to manage DDL yourself, use createStore() with manual migrations
instead.
Important: D1 has no interactive transaction primitive
(D1Database.batch(...) is transactional, but batch-only — not an
interactive runner), so store.transaction() is non-atomic on D1. See
Limitations for details. For a transactional Cloudflare
SQLite store, use Durable Objects (below) instead.
Cloudflare Durable Objects (SQLite)
Section titled “Cloudflare Durable Objects (SQLite)”A store backed by drizzle(ctx.storage) inside a Durable Object is
auto-detected as transactionMode: "do-sqlite" and reports
capabilities.transactions: true — no executionProfile hint needed.
Unlike D1, Durable Objects expose an interactive storage transaction runner,
so store.transaction() and store.withTransaction() are fully atomic.
import { drizzle } from "drizzle-orm/durable-sqlite";import { createStoreWithSchema } from "@nicia-ai/typegraph";import { createSqliteBackend } from "@nicia-ai/typegraph/sqlite";
export class MyObject { constructor(private ctx: DurableObjectState) {}
async handle() { const db = drizzle(this.ctx.storage); const backend = createSqliteBackend(db); // Boots schema/DDL outside any storage transaction (no DDL in the // business transaction); the schema-version commit uses the // do-sqlite runner. const [store] = await createStoreWithSchema(graph, backend);
// Atomic across TypeGraph + the product's own relational tables: await store.transaction(async (tx) => { await tx.nodes.Document.update(documentId, props); // tx.sql is the AdoptedTransaction union — cast to your db type. const sqlTx = tx.sql as typeof db; await sqlTx.insert(documentVersions).values(versionRow); }); }}TypeGraph delegates to the async storage runner
ctx.storage.transaction(async …) (Drizzle’s own db.transaction() on
Durable Objects is ctx.storage.transactionSync and cannot span an
await, so it is not used). See the
Cross-Store Transactions recipe
for the caller-owned (withTransaction) and graph-owned (tx.sql) shapes.
Backend Capabilities
Section titled “Backend Capabilities”Check what features a backend supports:
const backend = createSqliteBackend(db);
if (backend.capabilities.transactions) { await store.transaction(async (tx) => { /* ... */ });} else { // Handle non-transactional execution}
if (backend.capabilities.vector?.supported) { // Vector similarity queries available}Connection Management
Section titled “Connection Management”TypeGraph does not manage database connections. You are responsible for:
- Creating connections with appropriate configuration
- Connection pooling for production use
- Closing connections on shutdown
// You create the connectionconst sqlite = new Database("app.db");const db = drizzle(sqlite);const backend = createSqliteBackend(db);const store = createStore(graph, backend);
// You close the connectionprocess.on("exit", () => { sqlite.close();});The store.close() method is a no-op—cleanup is your responsibility.
Database roles & least privilege
Section titled “Database roles & least privilege”createStoreWithSchema() and createStore() divide cleanly along DDL
privilege, so a production deployment can run its application under a
least-privilege, DML-only database role.
-
createStoreWithSchema(graph, backend)runs DDL. It bootstraps the base tables on a fresh database, applies safe auto-migrations, and durably materializes strategy-owned runtime storage (e.g. fulltext). It re-issues idempotent DDL on every cold boot — at minimum aCREATE TABLE IF NOT EXISTSfor the contribution-marker table — so the role it runs under must holdCREATE/ DDL privileges. Run it once at startup, outside request handlers and transactions. -
createStore(graph, backend)is a synchronous, zero-I/O attach. It does not create tables, repair DDL, or record that runtime storage is materialized — it issues no DDL ever. Use it only to attach to a database a priorcreateStoreWithSchemaboot already initialized. A fulltext read or write against a database that was never initialized throwsStoreNotInitializedErrorrather than silently emitting DDL on the hot path. Graphs with nosearchable()fields are unaffected. -
createVerifiedStore(graph, backend)is the same zero-DDL attach with a verification gate. It reads the active schema row, folds the persisted graph extension, and refuses to construct the Store unless the database is at the same schema version as the code graph. ThrowsMigrationErroron drift (safe or breaking),ConfigurationErrorwhen no schema has been initialized, andStoreNotInitializedErrorwhen the schema is current but runtime-contribution markers are missing. The runtime-side counterpart ofcreateStoreWithSchemafor least-privilege deployments. If you only need the gate without building a Store (e.g. a readiness probe), callassertSchemaCurrent.
Recommended deployment shape
Section titled “Recommended deployment shape”Run schema/DDL changes as a privileged, one-time migration step, then
run the application under a least-privilege runtime role that holds
only SELECT / INSERT / UPDATE / DELETE:
// 1. Migration step — privileged role with DDL/CREATE.//// createStoreWithSchema is mandatory here: it bootstraps tables,// applies safe auto-migrations, commits the schema_versions row,// and writes the durable contribution markers. The runtime gate// checks all of those.const [ /* store */] = await createStoreWithSchema(graph, adminBackend);
// Optional prerequisite if you manage DDL externally with// drizzle-kit. Generated SQL creates the tables but does NOT// initialize the schema row or contribution markers — still run// createStoreWithSchema afterwards (it skips bootstrap when tables// already exist and commits the row + markers)://// import { generatePostgresMigrationSQL } from "@nicia-ai/typegraph/postgres";// await adminPool.query(generatePostgresMigrationSQL());// await createStoreWithSchema(graph, adminBackend);// 2. Runtime — least-privilege, DML-only role. Zero DDL.// createVerifiedStore fails fast if the privileged migrator is behind.const runtimePool = new Pool({ connectionString: process.env.APP_DATABASE_URL });const backend = createPostgresBackend(drizzle(runtimePool));const [store] = await createVerifiedStore(graph, backend);If the runtime role has no DDL privileges and you boot it with
createStoreWithSchema() anyway, the first cold boot fails with a
permission error on the bootstrap or contribution-marker DDL — see
Troubleshooting.
Environment-Specific Setup
Section titled “Environment-Specific Setup”Development
Section titled “Development”// In-memory for fast testsconst { backend } = createLocalSqliteBackend();
// Or file-based for persistence during developmentconst { backend } = createLocalSqliteBackend({ path: "./dev.db" });Testing
Section titled “Testing”// Fresh in-memory database per testbeforeEach(() => { const { backend } = createLocalSqliteBackend(); store = createStore(graph, backend);});Production
Section titled “Production”Single-role setup — createStoreWithSchema bootstraps and migrates on
boot, so the role needs DDL privileges. To run the application under a
least-privilege, DML-only role instead, split the migration step out as
described in Database roles & least privilege.
// PostgreSQL with poolingconst pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20, ssl: { rejectUnauthorized: false }, // For managed databases});
const db = drizzle(pool);const backend = createPostgresBackend(db);const [store] = await createStoreWithSchema(graph, backend);Next Steps
Section titled “Next Steps”- Schemas & Types - Define your graph schema
- Semantic Search - Vector embeddings and similarity search
- Limitations - Backend-specific constraints