Skip to content

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 is ideal for development, testing, single-server deployments, and embedded applications.

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);

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 database
const sqlite = new Database("app.db");
sqlite.pragma("journal_mode = WAL"); // Recommended for performance
sqlite.pragma("foreign_keys = ON");
// Create Drizzle instance and backend
const db = drizzle(sqlite);
const backend = createSqliteBackend(db);
// createStoreWithSchema auto-creates tables on first run
const [store] = await createStoreWithSchema(graph, backend);
// Clean up when done
process.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);

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 extension
sqlite.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.

For edge deployments, shared-driver setups, or Turso cloud databases, use the first-class libsql backend:

Terminal window
npm install @libsql/client
import { createClient } from "@libsql/client";
import { createLibsqlBackend } from "@nicia-ai/typegraph/sqlite/libsql";
import { createStore } from "@nicia-ai/typegraph";
// Local file
const 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.

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 };

Creates a SQLite backend from an existing Drizzle database instance.

function createSqliteBackend(db: BetterSQLite3Database, options?: { tables?: SqliteTables }): GraphBackend;

Returns SQL for creating TypeGraph tables in SQLite.

function generateSqliteMigrationSQL(): string;

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 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.

RuntimeRecommended driverDrizzle 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 overheaddrizzle-orm/postgres-js
Bun serverpostgres (postgres-js) or Bun’s built-in SQLdrizzle-orm/postgres-js or drizzle-orm/bun-sql
Edge runtime (Cloudflare Workers, Vercel Edge, Netlify Edge) — needs transactions@neondatabase/serverless Pool over WebSocketsdrizzle-orm/neon-serverless
Edge runtime — single-statement reads/writes only@neondatabase/serverless neon(url) over HTTPdrizzle-orm/neon-http
Cloudflare Hyperdrivepg or postgres (through the Hyperdrive pooler)drizzle-orm/node-postgres or drizzle-orm/postgres-js

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);

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.

Terminal window
npm install postgres drizzle-orm
import 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.

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.

Terminal window
npm install @neondatabase/serverless drizzle-orm
import { 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.

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.

Terminal window
npm install @neondatabase/serverless drizzle-orm
import { 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.

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 extension
await 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.

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.

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 errors
pool.on("error", (err) => {
console.error("Unexpected pool error", err);
});
// Graceful shutdown
process.on("SIGTERM", async () => {
await pool.end();
process.exit(0);
});

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;

Returns SQL for creating TypeGraph tables in PostgreSQL, including the pgvector extension.

function generatePostgresMigrationSQL(): string;

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[];

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";

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.

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.

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
}

TypeGraph does not manage database connections. You are responsible for:

  1. Creating connections with appropriate configuration
  2. Connection pooling for production use
  3. Closing connections on shutdown
// You create the connection
const sqlite = new Database("app.db");
const db = drizzle(sqlite);
const backend = createSqliteBackend(db);
const store = createStore(graph, backend);
// You close the connection
process.on("exit", () => {
sqlite.close();
});

The store.close() method is a no-op—cleanup is your responsibility.

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 a CREATE TABLE IF NOT EXISTS for the contribution-marker table — so the role it runs under must hold CREATE / 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 prior createStoreWithSchema boot already initialized. A fulltext read or write against a database that was never initialized throws StoreNotInitializedError rather than silently emitting DDL on the hot path. Graphs with no searchable() 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. Throws MigrationError on drift (safe or breaking), ConfigurationError when no schema has been initialized, and StoreNotInitializedError when the schema is current but runtime-contribution markers are missing. The runtime-side counterpart of createStoreWithSchema for least-privilege deployments. If you only need the gate without building a Store (e.g. a readiness probe), call assertSchemaCurrent.

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.

// In-memory for fast tests
const { backend } = createLocalSqliteBackend();
// Or file-based for persistence during development
const { backend } = createLocalSqliteBackend({ path: "./dev.db" });
// Fresh in-memory database per test
beforeEach(() => {
const { backend } = createLocalSqliteBackend();
store = createStore(graph, backend);
});

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 pooling
const 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);