Backend Setup
TypeGraph stores graph data in your existing relational database using Drizzle ORM adapters. This guide covers setting up SQLite, PostgreSQL, and PGlite 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 owns the connection and provisions TypeGraph’s base tables:
import { createLocalSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/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);The local backend owns its connection, so it applies performance pragmas at
open: journal_mode=WAL, synchronous=NORMAL, and a 5s busy_timeout. On
file databases this makes single-operation writes roughly 5× faster than the
driver defaults (rollback journal, synchronous=FULL). Override individual
values or opt out entirely:
// Override one value, keep the other defaultscreateLocalSqliteBackend({ path: "./app.db", pragmas: { busyTimeoutMs: 10_000 } });
// Keep better-sqlite3's driver defaults untouchedcreateLocalSqliteBackend({ path: "./app.db", pragmas: false });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/adapters/drizzle/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());For a fresh database whose DDL is managed externally, use
generateSqliteMigrationSQL() with createStore() instead:
sqlite.exec(generateSqliteMigrationSQL());const store = createStore(graph, backend);The generated script is complete installation DDL and stamps the current
deployment-wide base-schema marker last; it is not an incremental upgrade
planner. Existing databases attached only through the zero-DDL runtime
factories must apply release-specific additive migrations through their
migration tool. See
Upgrading deployment-wide base storage
for the exact SQLite and PostgreSQL statements. A privileged
createStoreWithSchema() open adopts missing release storage once, then stamps
a deployment-wide base-schema marker. Warm opens read that marker and issue no
base-adoption DDL.
SQLite with Vector Search
Section titled “SQLite with Vector Search”For semantic search, use the sqlite-vec extension. createLocalSqliteBackend() wires the
sqliteVecStrategy automatically when the extension loads. For a bring-your-own connection, load the
extension and pass the strategy explicitly:
import Database from "better-sqlite3";import { drizzle } from "drizzle-orm/better-sqlite3";import { createSqliteBackend, generateSqliteMigrationSQL } from "@nicia-ai/typegraph/adapters/drizzle/sqlite";import { sqliteVecStrategy } from "@nicia-ai/typegraph";
const sqlite = new Database("app.db");
// Load sqlite-vec extensionsqlite.loadExtension("vec0");
// Run migrations (core tables)sqlite.exec(generateSqliteMigrationSQL());
const db = drizzle(sqlite);const backend = createSqliteBackend(db, { vector: sqliteVecStrategy });sqlite-vec stores embeddings in vec0 virtual tables and supports the cosine and l2 metrics. Per-field
vector tables are provisioned by createStoreWithSchema at boot (not by the generated migration SQL), and the
runtime asserts a durable marker rather than issuing DDL on first write — see
Database roles & least privilege.
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/adapters/drizzle/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. Its installation is complete: the
factory publishes the deployment-wide base-schema marker, and when it encounters
a pre-0.52 edge table it applies the focused match-identity storage adoption
before retrying the idempotent installation script. The local SQLite factory has
the same behavior.
The libsql backend has native vector and hybrid search, wired automatically via libsqlVectorStrategy — no
extension to load. It uses libSQL’s built-in engine (F32_BLOB(N) storage, vector_distance_cos /
vector_distance_l2, and DiskANN approximate nearest neighbor via libsql_vector_idx + vector_top_k) and
supports the cosine and l2 metrics. See Semantic Search for query examples.
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; /** * Override the fulltext strategy. Defaults to `fts5Strategy` (SQLite's * built-in FTS5 virtual table). Pass `false` to disable fulltext support * entirely — the backend then advertises no `capabilities.fulltext` and * omits the fulltext CRUD/search methods, and the managed installation * never creates the fulltext table. Forwarded to both the installation * DDL and `createSqliteBackend`. */ fulltext?: FulltextStrategy | false;}): { backend: GraphBackend; db: BetterSQLite3Database };createSqliteBackend(db, options?)
Section titled “createSqliteBackend(db, options?)”Creates a SQLite backend from an existing Drizzle database instance. Pass vector to enable vector search
(for example sqliteVecStrategy after loading the sqlite-vec extension).
function createSqliteBackend( db: BetterSQLite3Database, options?: { tables?: SqliteTables; /** * Override the fulltext strategy. Defaults to `fts5Strategy` (SQLite's * built-in FTS5 virtual table). Pass `false` to disable fulltext * support entirely — the backend then advertises no * `capabilities.fulltext` and omits the fulltext CRUD/search methods, * mirroring `vector` left unset. Required for a SQLite build without * FTS5 compiled in. */ fulltext?: FulltextStrategy | false; vector?: VectorStrategy; capabilities?: BundledBackendCapabilityOverrides; },): GraphBackend;Pass { fulltext: false } on a SQLite build without FTS5 compiled in, or
whenever the graph has no searchable() fields and you would rather skip
the virtual table than carry it unused:
const backend = createSqliteBackend(db, { fulltext: false });generateSqliteMigrationSQL()
Section titled “generateSqliteMigrationSQL()”Returns complete fresh-installation SQL for creating TypeGraph tables and stamping the current deployment-wide base-schema marker in SQLite.
function generateSqliteMigrationSQL( tables?: SqliteTables, fulltextStrategy?: FulltextStrategy | false,): string;generateSqliteDDL() is the lower-level table/index statement array used by
backend bootstrap. It deliberately omits the deployment-wide marker row and is
therefore not a complete installation script. Use generateSqliteMigrationSQL()
when the resulting database will be opened through createVerifiedStore() or
the DML-only graph-template APIs.
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 |
| Embedded apps, local development, Postgres dialect tests | @electric-sql/pglite |
drizzle-orm/pglite |
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/adapters/drizzle/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);For a fresh database managed externally, use generatePostgresMigrationSQL() with createStore():
import { generatePostgresMigrationSQL } from "@nicia-ai/typegraph/adapters/drizzle/postgres";
await pool.query(generatePostgresMigrationSQL());const store = createStore(graph, backend);As with SQLite, this is complete installation DDL rather than an incremental
upgrade plan. Apply the
base-schema upgrade
to an existing database, or let a privileged createStoreWithSchema()
preparation adopt the storage before runtime workers use createStore().
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/adapters/drizzle/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/adapters/drizzle/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.execution.interactiveTransactions to false and
capabilities.execution.unitOfWork to "batch". On a raw Store,
store.transaction(...) refuses rather than silently falling through to
sequential execution.
A schema-managed or verified Store’s first write does not universally fail
closed here — it depends on whether the write fuses. A singleton node
create, update, upsertById, or delete fuses on a kind with no declared
unique constraint (a create takes a generated or a caller-supplied id) —
except a node delete, which fuses even when the kind DOES carry a declared
unique constraint, because the atomic delete program releases that claim in
the same statement. A singleton edge create fuses when the kind’s
cardinality is "many", and edge update and delete fuse the same way
(EdgeCollection has no upsertById). So do
bulkInsert/bulkCreate/bulkDelete/bulkReplaceById/bulkUpsertById,
and a constrained write inside an atomic program’s claim envelope. Each of
these asserts the active schema version inside the statements neon-http
submits together, and transaction(queries) commits or rejects that
submission as a whole. A write that cannot fuse either fails closed with
BATCH_WRITE_UNSUPPORTED naming a proven reason (an interactive callback, a
probe-then-write constraint check, Operational Identity, history, or a
schema commit), or — for a write that simply doesn’t fit the fused shape,
such as a singleton create, update, or upsertById on a uniquely-constrained
kind, or a supplied-id tombstone resurrection — fails closed with the plain
SCHEMA_WRITE_FENCE_UNSUPPORTED limitation and no named reason. See
The guard every fused write shares
for the shared guard and the full reason table.
Schema commits stay refused regardless: commitSchemaVersion and
setActiveVersion require holding one transaction across their
compare-and-swap read and activating write 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 for the fused writes
above. A raw createStore() remains available for writes outside that
envelope when the application explicitly accepts they are not fenced against
schema changes.
npm install @neondatabase/serverless drizzle-ormimport { neon } from "@neondatabase/serverless";import { drizzle } from "drizzle-orm/neon-http";import { createPostgresBackend } from "@nicia-ai/typegraph/adapters/drizzle/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.execution.interactiveTransactions === false (auto-detected)Use neon-http for reads and for the fused schema-managed writes listed
above. Run schema migrations, and any write outside that envelope, through
neon-serverless, regular pg, or another transactional driver.
PGlite (Postgres-in-WASM)
Section titled “PGlite (Postgres-in-WASM)”PGlite is a full Postgres compiled to WebAssembly that runs in-process — in Node, Bun, Deno, or the browser — with no server and no native addon. It’s ideal for local development, embedded apps, and running the real Postgres dialect (including pgvector) in tests without Docker.
@electric-sql/pglite is an optional peer dependency. Vector support additionally
needs @electric-sql/pglite-pgvector (PGlite ≥ 0.5 ships pgvector as a separate
package):
npm install @electric-sql/pglite @electric-sql/pglite-pgvectorThe batteries-included helper constructs the engine, loads pgvector, runs the
schema DDL, and returns a ready backend — the Postgres analog of
createLocalSqliteBackend:
import { createLocalPgliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/postgres/pglite";import { createStore } from "@nicia-ai/typegraph";
// In-memory by default, with pgvector enabled.const { backend, db, client } = await createLocalPgliteBackend();const store = createStore(graph, backend);
// backend.close() disposes the PGlite engine.// Persistent on disk:const { backend } = await createLocalPgliteBackend({ dataDir: "./pgdata" });
// No embeddings? Skip the extension (no pgvector dependency needed):const { backend } = await createLocalPgliteBackend({ vector: false });
// Pass an explicit pgvector extension object:import { vector } from "@electric-sql/pglite-pgvector";const { backend } = await createLocalPgliteBackend({ vector });If you construct PGlite yourself, pass its Drizzle database straight to
createPostgresBackend — the execution fast path detects PGlite and routes it
correctly:
import { PGlite } from "@electric-sql/pglite";import { vector } from "@electric-sql/pglite-pgvector";import { drizzle } from "drizzle-orm/pglite";import { createPostgresBackend, generatePostgresMigrationSQL } from "@nicia-ai/typegraph/adapters/drizzle/postgres";
const client = await PGlite.create({ extensions: { vector } });await client.exec(generatePostgresMigrationSQL());const backend = createPostgresBackend(drizzle(client));PGlite is single-connection and serial: there is no pooling, so concurrent
store.transaction() calls queue rather than run in parallel. It complements,
rather than replaces, a Docker-based Postgres for CI — PGlite exercises the SQL
dialect and pgvector, but not driver-specific behavior (node-postgres statement
naming, postgres-js, pgbouncer, real concurrency).
PostgreSQL with Vector Search
Section titled “PostgreSQL with Vector Search”For semantic search, enable pgvector. createPostgresBackend defaults to pgvectorStrategy, so no extra
wiring is required:
import { Pool } from "pg";import { drizzle } from "drizzle-orm/node-postgres";import { createPostgresBackend, generatePostgresMigrationSQL } from "@nicia-ai/typegraph/adapters/drizzle/postgres";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Migration SQL enables the pgvector extensionawait pool.query(generatePostgresMigrationSQL());// Runs: CREATE EXTENSION IF NOT EXISTS vector;
const db = drizzle(pool);const backend = createPostgresBackend(db);pgvector stores embeddings in per-field typed vector(N) tables (provisioned by createStoreWithSchema at boot
— the generated migration SQL creates no embedding table) with HNSW or IVFFlat indexes, and supports the
cosine, l2, and inner_product metrics.
See Semantic Search for query examples.
Refreshing planner statistics after bulk loads
Section titled “Refreshing planner statistics after bulk loads”importGraph() refreshes planner statistics automatically after an import
that created or updated rows, and store.materializeIndexes() does the
same on SQLite after creating indexes (pass refreshStatistics: false to
opt out). On PostgreSQL, materializeIndexes() builds with
CREATE INDEX CONCURRENTLY and skips the automatic refresh — call
store.refreshStatistics() after materializing.
bulkCreate and bulkInsert on nodes and edges also refresh
automatically when a single autocommit call writes 1,000 rows or more. Tune or disable this
with the autoRefreshStatistics store option:
// Refresh after any autocommit bulkCreate of 5,000+ rowsconst store = createStore(graph, backend, { autoRefreshStatistics: 5000 });
// Never refresh automatically after bulkCreateconst store = createStore(graph, backend, { autoRefreshStatistics: false });Bulk writes inside a store.transaction(...) block never auto-refresh —
statistics collected mid-transaction cannot see the uncommitted rows —
so refresh manually after the transaction commits. The same applies to
loops of small bulkCreate batches that never individually reach the
threshold, and to backend-level batch inserts — the loop example below
covers that pattern.
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. Cloudflare D1 and Durable Object SQLite
reject the performance-only PRAGMA analysis_limit tuning statement through
their authorizer. TypeGraph recognizes only that SQLITE_AUTH failure and
continues with scoped ANALYZE; workerd permits ANALYZE, so planner statistics
are still refreshed but without bounded sampling. Unexpected PRAGMA or ANALYZE
failures stay visible through the existing caller warning or rejection. 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 in-process cache that maps SQL text → statement name is LRU-bounded
(default 256 entries, override via preparedStatementCacheMax). Eviction
never recycles a name, because a live connection may still retain that name for
its original SQL. Therefore this setting does not bound server-side prepared
statement memory. For a high-cardinality stream of SQL text, use
prepareStatements: false instead.
Authoritative command sessions
Section titled “Authoritative command sessions”Store create paths use the backend’s commands port for writes whose
decision and mutation must share one command boundary. First-party paths pass
an explicit command context: a root port owns any internal transaction it
needs and cannot inherit caller coordination, while a transaction-scoped
backend uses the active caller or Store transaction. A
transaction command may additionally carry a coordination token only after it
has acquired the graph’s advisory lock; the token is bound to that graph and
transaction session and cannot authorize work on another connection.
On PostgreSQL, the lock statement also observes the effective transaction
isolation and binds it to the same token. Match-key convergence therefore
accepts only read committed or serializable based on database state, not the
caller-requested option or the server’s assumed default.
GraphBackend.commands is a required member as of the authoritative command
port release. Custom backends must expose { session, execute } and implement
the node.create, edge.create, and edge.converge-create commands, or return
a typed unsupported result for dimensions they do not provide. The former
optional managed-create and specialized edge-insert hooks are no longer a
complete backend implementation; migrate those branches into the command
port before upgrading.
For a custom backend, the migration shape is:
const commands: GraphCommandPort = { session: "transaction", // use "root" for a single-statement backend execute(command, context) { // Apply every requested dimension, or explicitly refuse the command. switch (command.kind) { case "node.create": { return { outcome: "unsupported", entity: "node", dimensions: ["claims"] }; } case "edge.create": { return { outcome: "unsupported", entity: "edge", dimensions: ["endpointPredicate"], }; } case "edge.converge-create": { return { outcome: "unsupported", entity: "edge", dimensions: ["convergence"] }; } } },};const backend: GraphBackend = { ...members, commands };Every command port caller must provide the explicit context. TypeGraph-owned
write paths use the command helper, which verifies that any coordination token
belongs to the active graph and transaction session and carries a supported
effective isolation before executing convergence. The portable PostgreSQL
graph-lock path records that isolation automatically. A custom implementation
of lockSchemaVersionAndGraphWrite must return the normalized
GraphCommandIsolation observed by its combined lock statement. When
decorating a first-party backend with deriveBackend, a same-session
commands override retains the session identity. A wrapper that changes
session or forwards to a different connection is a new command boundary and
cannot reuse a token from the original port.
These are four different execution guarantees; do not use “atomic” as a catch-all:
- Interactive transaction (
store.transaction(...)) pins one session and can make several Store operations commit or roll back together. TherunOptionallyInTransactioncallback receives{ mode: "interactive-transaction" }when this boundary was opened, or{ mode: "sequential" }on a backend without transaction support. - Static internal adapter batch is an adapter implementation detail (for example, a D1 batch or a bind-budgeted multi-row insert). It may make one precompiled set of statements atomic, but it is not a public Store transaction and does not make an arbitrary sequence of Store calls atomic.
- Certified atomic SQL program is the backend-authoring transport seam for a closed, ordered sequence of statements. A backend earns this capability by passing the framework-agnostic conformance runner: result slots and bound parameters must be preserved, a failure in a later statement must leave no primary or sidecar writes, and an empty program must be a no-op. Certification is separate from semantic mutation eligibility; a transport alone does not authorize a mutation family. Bundled recognized PostgreSQL drivers provide this boundary either through Neon HTTP’s transaction batch or a pinned interactive transaction; an unrecognized driver leaves it unavailable.
- Authoritative one-statement command is the
commands.executeport. A command returns a created/found/rejected/unsupported result after the database statement itself owns the decision and mutation. It is the transactionless path for eligible durable edgematchIdentityconvergence; it is not a promise that every command or side effect can be fused.
Operational Identity, single-edge claim/cardinality checks, and undeclared
dynamic matchOn convergence remain interactive-transaction contracts. A
custom or non-transactional backend must refuse those dimensions rather than
silently falling through to a sequence of independent statements. Eligible
direct edge batches on bundled roots are a narrower static-program contract:
the insert and cardinality sidecars execute in one native atomic exchange. A
declared durable edge matchIdentity is different for endpoint convergence:
its canonical key has a database arbiter, so the eligible root create/found
command can be authoritative in one statement.
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,
drizzle-orm/neon-http, and drizzle-orm/pglite. The neon-http driver is auto-detected and
capabilities.execution.interactiveTransactions 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; /** * Override the fulltext strategy. Defaults to `tsvectorStrategy`. * Pass a custom `FulltextStrategy` to swap the fulltext stack, or * `false` to disable fulltext support entirely — the backend then * advertises no `capabilities.fulltext` and omits the fulltext * CRUD/search methods, mirroring `vector: false`. */ fulltext?: FulltextStrategy | false; /** * Override the vector search strategy. Defaults to * `pgvectorStrategy`. Pass a custom `VectorStrategy` to change the * storage / index engine, or `false` to disable vector support. */ vector?: VectorStrategy | false; /** * Override specific backend capabilities. Useful for HTTP-style * drivers or test scenarios. neon-http already has * `execution.interactiveTransactions: false` auto-applied — pass * this to override that or to disable other capabilities for custom * drivers. */ capabilities?: BundledBackendCapabilityOverrides; /** * 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;Pass { fulltext: false } when the graph has no searchable() fields and
you would rather skip the fulltext table (typegraph_node_fulltext) and its
GIN index than carry them unused:
const backend = createPostgresBackend(db, { fulltext: false });createLocalPgliteBackend(options?)
Section titled “createLocalPgliteBackend(options?)”Creates an in-process PGlite backend with automatic engine construction,
schema DDL, and optional pgvector loading. The returned backend owns the PGlite
engine; call backend.close() when the process or test is done.
async function createLocalPgliteBackend(options?: { /** * PGlite data directory. Omit for an in-memory database, pass a filesystem * path for persistence, or use a runtime-specific scheme such as `idb://`. */ dataDir?: string; tables?: PostgresTables; /** * Omit to load @electric-sql/pglite-pgvector, pass `false` to disable vector * support, or pass a PGlite Extension object to control the extension import. */ vector?: false | Extension; /** * Override the fulltext strategy. Defaults to `tsvectorStrategy`. Pass * `false` to disable fulltext support entirely — the backend then * advertises no `capabilities.fulltext` and omits the fulltext CRUD/search * methods, and the installation DDL never creates the fulltext table. */ fulltext?: FulltextStrategy | false;}): Promise<{ backend: GraphBackend; db: PgliteDatabase; client: PGlite;}>;generatePostgresMigrationSQL()
Section titled “generatePostgresMigrationSQL()”Returns complete fresh-installation SQL for creating TypeGraph tables and stamping the current deployment-wide base-schema marker in PostgreSQL. It includes the pgvector extension. The vector-disabled local PGlite factory uses the same installation builder internally while omitting only that extension.
function generatePostgresMigrationSQL( tables?: PostgresTables, fulltextStrategy?: FulltextStrategy | false,): 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. This low-level array deliberately omits the deployment-wide
marker row, so joining it does not produce a complete installation. Use
generatePostgresMigrationSQL() for a database that will be opened through
createVerifiedStore() or the DML-only graph-template APIs.
function generatePostgresDDL( tables?: PostgresTables, fulltextStrategy?: FulltextStrategy | false,): string[];Upgrading deployment-wide base storage
Section titled “Upgrading deployment-wide base storage”Skip this section when createStoreWithSchema() or
createAdapterStoreWithSchema() owns schema preparation: the bundled SQLite
and PostgreSQL adapters adopt each numbered base-schema release automatically
on the first privileged open. No separate bootstrap command is needed. The
deployment invariant is ordering: that privileged open must finish before any
DML-only runtime worker starts. Base-schema version 1 includes the durable graph
template relation and edge match-identity storage. It is required even for
graphs without a matchIdentity declaration because every edge write names the
two nullable columns.
When database DDL is managed externally, apply the matching migration before a runtime worker opens the new graph schema. Apply the marker write last: it is the durable proof that every preceding step succeeded. The examples use the default TypeGraph table names. Replace every occurrence consistently when the adapter uses custom table names.
For SQLite, run this migration exactly once. SQLite has no portable ADD COLUMN IF NOT EXISTS, so a migration tool must record whether it has already applied
the two ALTER TABLE statements. Fresh and published schemas include the
nullable-pair CHECK below. Privileged adoption accepts an externally managed
table that already has both columns without that defensive constraint: SQLite
does not expose structural CHECK metadata or support adding one without a full
table rebuild, while TypeGraph writes always bind both values or neither.
CREATE TABLE IF NOT EXISTS "typegraph_graph_templates" ( "template_id" TEXT PRIMARY KEY NOT NULL, "schema_hash" TEXT NOT NULL, "schema_doc" TEXT NOT NULL, "created_at" TEXT NOT NULL);
ALTER TABLE "typegraph_edges" ADD COLUMN "match_identity_name" TEXT;
ALTER TABLE "typegraph_edges" ADD COLUMN "match_identity_key" TEXT CHECK (("match_identity_name" IS NULL) = ("match_identity_key" IS NULL));
CREATE UNIQUE INDEX IF NOT EXISTS "typegraph_edges_match_identity_uq" ON "typegraph_edges" ( "graph_id", "kind", "match_identity_name", "match_identity_key" );
CREATE TABLE IF NOT EXISTS "typegraph_base_schema_versions" ( "installation" INTEGER PRIMARY KEY NOT NULL, "version" INTEGER NOT NULL, "updated_at" TEXT NOT NULL, CONSTRAINT "typegraph_base_schema_versions_singleton_check" CHECK ("installation" = 1));
INSERT INTO "typegraph_base_schema_versions" ("installation", "version", "updated_at")VALUES (1, 1, CURRENT_TIMESTAMP)ON CONFLICT ("installation") DO UPDATE SET "version" = excluded."version", "updated_at" = excluded."updated_at"WHERE "typegraph_base_schema_versions"."version" <= excluded."version";For PostgreSQL, the adoption statements are idempotent:
CREATE TABLE IF NOT EXISTS "typegraph_graph_templates" ( "template_id" TEXT PRIMARY KEY NOT NULL, "schema_hash" TEXT NOT NULL, "schema_doc" JSONB NOT NULL, "created_at" TIMESTAMPTZ NOT NULL);
ALTER TABLE "typegraph_edges" ADD COLUMN IF NOT EXISTS "match_identity_name" TEXT;
ALTER TABLE "typegraph_edges" ADD COLUMN IF NOT EXISTS "match_identity_key" TEXT;
DO $$BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass('"typegraph_edges"') AND conname = 'typegraph_edges_match_identity_pair_check' ) THEN ALTER TABLE "typegraph_edges" ADD CONSTRAINT "typegraph_edges_match_identity_pair_check" CHECK ( ("match_identity_name" IS NULL) = ("match_identity_key" IS NULL) ); END IF;END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "typegraph_edges_match_identity_uq" ON "typegraph_edges" ( "graph_id", "kind", "match_identity_name", "match_identity_key" );
CREATE TABLE IF NOT EXISTS "typegraph_base_schema_versions" ( "installation" INTEGER PRIMARY KEY NOT NULL, "version" INTEGER NOT NULL, "updated_at" TIMESTAMPTZ NOT NULL, CONSTRAINT "typegraph_base_schema_versions_singleton_check" CHECK ("installation" = 1));
INSERT INTO "typegraph_base_schema_versions" ("installation", "version", "updated_at")VALUES (1, 1, NOW())ON CONFLICT ("installation") DO UPDATE SET "version" = excluded."version", "updated_at" = excluded."updated_at"WHERE "typegraph_base_schema_versions"."version" <= excluded."version";The conditional update makes marker publication monotonic: replaying an older
migration can never claim that storage prepared by a newer TypeGraph release is
older. The fresh-installation generators use DO NOTHING instead because they
are not upgrade planners; an existing stale marker remains stale until the
numbered privileged adoption lifecycle runs.
createVerifiedStore, assertSchemaCurrent, and the DML-only graph-template
APIs read this marker and throw BaseSchemaMigrationError when it is missing,
stale, or newer than the running library. They never attempt repair. A plain
createStore remains a synchronous zero-I/O attach; if it reaches an edge
write on legacy storage, the write fails with ConfigurationError and
details.code === "EDGE_MATCH_IDENTITY_STORAGE_UNAVAILABLE" rather than a raw
missing-column error.
Provisioning the columns does not authorize re-keying existing data. Adding,
removing, renaming, or changing the fields of a declared matchIdentity remains
a breaking graph-schema change while that edge kind has any physical rows,
including tombstones. Export the affected edges, hard-delete them, publish the
new schema, and import them again so every row receives a key under the new
declaration.
Drizzle-Free Entrypoints
Section titled “Drizzle-Free Entrypoints”TypeGraph keeps its public core and backend contracts independent of Drizzle:
@nicia-ai/typegraph/coreexports graph definition helpers and their schema-derived types for packages that only define or share schemas.@nicia-ai/typegraph/backendexports the complete backend, dialect, SQL-fragment, fulltext, and vector strategy contracts for adapter authors.@nicia-ai/typegraph/sqlite/localand@nicia-ai/typegraph/postgres/pglitecreate managed Stores without exposing adapter-native handles.
Application code can continue importing the complete portable Store API from
@nicia-ai/typegraph. Use the /adapters/drizzle/... entrypoints only when the
application deliberately owns a Drizzle connection or needs native transaction
interop.
Custom insert builders must apply the same born-ended validity rule as the built-in adapters. Import its public owner instead of duplicating the bound comparison:
import { resolveStampedValidityLowerBound } from "@nicia-ai/typegraph/backend";
const validFrom = resolveStampedValidityLowerBound( params.validFrom, params.validTo, writeInstant,);Use the same writeInstant for the decision and the row’s creation/update
stamp. This keeps custom node and edge inserts, plus node resurrection paths
that reset the validity window, aligned with Store and interchange semantics at
the zero-width boundary. Edge resurrection retains its stored lower bound and
does not use this stamping helper.
Managed Store Entrypoints
Section titled “Managed Store Entrypoints”For local applications that do not need direct database access, TypeGraph can own the connection, provision its schema, and return the complete typed Store:
@nicia-ai/typegraph/sqlite/local— Node-only SQLite through the native better-sqlite3 addon@nicia-ai/typegraph/postgres/pglite— in-process PostgreSQL through PGlite’s WebAssembly runtime
import { createLocalSqliteStore } from "@nicia-ai/typegraph/sqlite/local";import { createLocalPgliteStore } from "@nicia-ai/typegraph/postgres/pglite";
const sqliteStore = await createLocalSqliteStore(graph, { path: "./graph.db" });const postgresStore = await createLocalPgliteStore(graph, { vector: false });These entrypoints expose no adapter-native database handle. The returned
Store keeps the complete graph API, including graph-owned
store.transaction(...), but intentionally omits withTransaction and
withRecordedTransaction, which require a caller-owned adapter handle. The
Store owns its connection, so call store.close() during shutdown. Its
declaration surface is safe for strict TypeScript consumers that do not install
unused database drivers.
PGlite vector support is enabled by default and loads the optional
@electric-sql/pglite-pgvector package. Install that package when using vector
fields, or pass { vector: false } as above for a smaller non-vector setup.
Both managed entrypoints also accept fulltext: false, which skips the
fulltext table at bootstrap and returns a backend with no
capabilities.fulltext.
Both factories accept store and schemaManagement groups, so the managed
path supports the same hooks, history/revision tracking, custom SQL schema,
query defaults, and migration policy as createStoreWithSchema:
import { createSqlSchema } from "@nicia-ai/typegraph";
const store = await createLocalSqliteStore(graph, { path: "./graph.db", pragmas: { busyTimeoutMs: 10_000 }, store: { history: true, schema: createSqlSchema({ nodes: "app_nodes", edges: "app_edges", fulltext: "app_fulltext", uniques: "app_uniques", }), }, schemaManagement: { systemIndexes: "skip" },});When a custom SQL schema is supplied, the managed factory provisions those same physical table names; no separate Drizzle table configuration is needed.
drizzle-orm is an optional peer dependency for these two managed
entrypoints: they load it only when their factory is called and, when it is
absent, reject with a typed ConfigurationError (MISSING_PEER_DEPENDENCY)
naming the package and the install command (npm install drizzle-orm) rather
than a bare module-resolution stack. The explicit /adapters/drizzle/...
entrypoints below expose Drizzle-native backends, connections, or schema
builders — or, for /adapters/drizzle/engine, the factory that assembles a
backend from a caller-supplied engine profile — and load drizzle-orm when
the module is evaluated. Importing one without the peer installed therefore
surfaces the raw module-resolution error, which names the same package.
Drizzle Adapter Entrypoints
Section titled “Drizzle Adapter Entrypoints”TypeGraph exposes Drizzle adapters through public entrypoints:
@nicia-ai/typegraph/adapters/drizzle/indexes— Drizzle schema-builder helpers for TypeGraph index declarations@nicia-ai/typegraph/adapters/drizzle/sqlite— Generic SQLite adapter (any Drizzle SQLite driver)@nicia-ai/typegraph/adapters/drizzle/sqlite/local— Batteries-included better-sqlite3 wrapper (Node.js only)@nicia-ai/typegraph/adapters/drizzle/sqlite/libsql— Batteries-included libsql wrapper (Node.js, Workers, browser)@nicia-ai/typegraph/adapters/drizzle/postgres— PostgreSQL adapter (any Drizzle Postgres driver)@nicia-ai/typegraph/adapters/drizzle/postgres/pglite— Batteries-included PGlite (Postgres-in-WASM) wrapper@nicia-ai/typegraph/adapters/drizzle/engine—createSqlBackend,deriveEngineProfile, the bundled builders,SqlEngineProfile
Import from the entrypoint matching your database:
import { createSqliteBackend, tables } from "@nicia-ai/typegraph/adapters/drizzle/sqlite";import { createLocalSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite/local";import { createLibsqlBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite/libsql";import { createPostgresBackend, tables } from "@nicia-ai/typegraph/adapters/drizzle/postgres";import { createLocalPgliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/postgres/pglite";Engine profiles
Section titled “Engine profiles”createPostgresBackend and createSqliteBackend are each createSqlBackend
applied to a profile built by buildPostgresEngineProfile /
buildSqliteEngineProfile, both exported alongside createSqlBackend and
deriveEngineProfile from the engine entrypoint:
import { buildPostgresEngineProfile, createSqlBackend,} from "@nicia-ai/typegraph/adapters/drizzle/engine";
const backend = createSqlBackend(buildPostgresEngineProfile(db, options));Most callers adapting a bundled backend want deriveEngineProfile, which
builds a variant of a bundled profile — a different lock spelling, a looser
declared capability, a replaced resource-audit verdict — without hand-copying
every other field. A profile written from scratch is not constructible today:
the assembly constructor is unexported, and createSqlBackend refuses a
hand-built assembly. See Authoring an engine profile for
the derivable-field table, the refusals a custom profile can hit, a worked
example, and what is not derivable yet.
A profile owns everything that genuinely differs between engines: dialect
tokens, the execution adapter, transaction framing, its fenceSql lock
spelling (see Write fence declaration),
provisioning DDL, strategies, and limits. createSqlBackend owns everything that is the
same for every SQL engine: deriving the final capabilities, resolving the
write-fence decision once, assembling the mirrored member groups, auditing
the backend’s resource shape, and applying the trust marks. A backend minted
this way earns the marks its own declarations back: the schema-fenced-insert
mark only when the resolved fence plan actually fences writers, the
root-autocommit mark only when the profile declares single-statement
durability, and the atomic-program registrations only when its capabilities
support root atomic batching — whether the profile is for a PostgreSQL-wire
engine with a different locking story or an embedded engine with a different
transaction model.
createSqlBackend refuses a profile whose resolved capabilities omit
writeFence — every mark and registration it applies assumes a
resolvable write-fence decision, and a profile that does not declare one
cannot back that decision soundly. buildPostgresEngineProfile and
buildSqliteEngineProfile are the reference profiles to read when modeling a
new one.
A profile’s provisioning.catalog supplies the backend’s optional catalog
member: physical-schema introspection — table and index existence, each
column’s normalized type family and raw declared type (a CatalogColumn is
{ name, kind, declaredType }; declaredType is required, and every custom
columnTypes implementation must populate it), and this engine’s
index-build facts — for the handful of store paths that need to read the
engine catalog directly instead of compiling a portable query: index
materialization (store.materializeIndexes() refuses only once its
empty-candidate short circuit and the status-table ensure step have already
run; store.materializeSystemIndexes(), which has no candidate short
circuit, refuses only once that same status-table ensure step has run), the
recorded-time schema check, and the recorded-time migration’s column read. A
profile that leaves catalog unset builds a backend with no catalog
member at all; those paths then refuse with a ConfigurationError naming
catalog rather than guessing at engine-specific SQL.
A dialect also declares subgraphMembershipStrategy, naming a decision the
dialect adapter makes, not one a profile supplies directly — the dialect
adapters are a fixed record keyed by SqlDialect, and each adapter’s
capabilities (DialectCapabilities) declares subgraphMembershipStrategy, so a
profile inherits whichever of the two dialects its own dialect field names. It
is the plan-shape choice behind store.subgraph()’s reachable-node filter:
"materialized-ids" fetches the traversal closure once and filters both the
node and edge queries against that fixed id list (the shape PostgreSQL uses,
trading one extra round trip for a parameter-driven plan), while "inline-cte"
embeds the recursive closure in each fetch instead (the shape SQLite uses, where
an in-process traversal is cheap and a growing parameter list would pressure the
bind budget). This is a control-flow and prepared-plan decision, not SQL text a
shared token could express identically on both shapes, so it lives on
DialectCapabilities rather than in the query compiler.
instantiateStatement — a member of the profile’s graphTemplateRuntime bag, and so one of the
fields deriveEngineProfile can override — is a required builder cloning a durable schema template
into a fresh graph. Given the template and target graph’s ids and schema hashes
(InstantiateGraphTemplateSqlParams: templateId, templateSchemaHash, graphId, schemaHash,
and the three physical table names it reads), it must return the statement that inserts the target
graph’s schema_versions row from the template’s stored document and copies the template’s
contribution-marker rows into the target graph — taking the target graph’s write lock, the same key
the schema-commit fence takes, co-atomically with the insert on an engine that fences with locks. An
engine whose dialect can compose a data-modifying CTE beside the schema INSERT (PostgreSQL) folds
the marker copy and the lock into that one statement; an engine that cannot (SQLite) instead
supplies the optional copyContributionMarkers dep, which runs the marker copy as a second
statement once the schema row is confirmed. The bundled
postgresInstantiateGraphTemplateStatement and sqliteInstantiateGraphTemplateStatement builders
(graph-template-sql.ts) are what createPostgresBackend and createSqliteBackend supply to
their own profiles; neither is exported, so a from-scratch profile reaches the same shape only by
copying a bundled profile and adapting its statement, while a derived profile can replace the whole
graphTemplateRuntime bag through deriveEngineProfile.
FenceSql (see Write fence declaration)
declares advisoryLockExpression and isolationFactExpression as the two
composable, no-SELECT forms a backend author supplies; TypeGraph derives
the standalone-statement counterparts (advisoryLock,
advisoryLockWithIsolation, isolationFact) from them. A statement that
must compose a lock or an isolation read INSIDE a larger query it builds
itself — a CTE, a data-modifying statement — embeds the bare expression
directly, rather than running the derived standalone form as its own
preceding statement. The schema write fence’s fused schema + graph-write
statement (postgres-schema-write-fence.ts) is the one site that needs
this: it puts the expression in its own CTE’s SELECT ... AS "lock_token",
resolving the fence target’s OWN FenceSql — the bundled postgresFenceSql
for a bundled backend, a derived profile’s own override otherwise — so a
custom spelling backs this fused statement exactly as it backs every
ordinary lock site.
The graph-template instantiation statement’s locked AS (SELECT ...) CTE is
a DIFFERENT case, not a FenceSql consumer at all: it composes the baked
single-argument advisoryLockSingleExpression directly.
The ONE lock form with no override point is advisoryLockSingleExpression,
the ONE-argument form on a bare key: PostgreSQL stores it in a lock space
distinct from every namespaced two-argument lock, and the schema-commit fence
and graph-template instantiation both take it on the same key so the two
mutually exclude. It is not a FenceSql member — both bundled builders bake it
in directly, and a custom profile has no way to replace it.
Cloudflare D1
Section titled “Cloudflare D1”TypeGraph supports Cloudflare D1 for edge deployments, with some limitations.
Cloudflare D1 has no interactive transaction primitive, so it cannot commit
TypeGraph schema versions: commitSchemaVersion / setActiveVersion need to
hold one transaction across their compare-and-swap read and activating
write, and D1 has no session to hold it on. Apply the base DDL with Wrangler
/ drizzle-kit. capabilities.execution.unitOfWork reports "batch". A
singleton node create, update, upsertById, or delete fuses on a kind with
no declared unique constraint (a create takes a generated or a
caller-supplied id) — except a node delete, which fuses even when the kind
DOES carry a declared unique constraint, because the atomic delete program
releases that claim in the same statement. A singleton edge create fuses
when the kind’s cardinality is "many", and edge update and delete fuse the
same way (EdgeCollection has no upsertById). So do
bulkInsert/bulkCreate/bulkDelete/bulkReplaceById/bulkUpsertById,
and a constrained write inside an atomic program’s claim envelope. Each of
these asserts the active schema version inside the statements
D1Database.batch() runs together, so these succeed on a schema-managed
Store. A write that cannot fuse either fails closed with
BATCH_WRITE_UNSUPPORTED naming a proven reason (a probe-then-write
constraint check, an interactive callback, Operational Identity, history, or
a schema commit), or — for a write that simply doesn’t fit the fused shape,
such as a singleton create, update, or upsertById on a uniquely-constrained
kind, or a supplied-id tombstone resurrection — fails closed with the plain
SCHEMA_WRITE_FENCE_UNSUPPORTED limitation and no named reason; see
The guard every fused write shares
for the full reason table. Use a raw createStore() only when the
application accepts unfenced writes for the remaining paths:
import { drizzle } from "drizzle-orm/d1";import { createStore } from "@nicia-ai/typegraph";import { createSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite";
export default { async fetch(request: Request, env: Env) { const db = drizzle(env.DB); const backend = createSqliteBackend(db); const store = createStore(graph, backend);
// Use store... },};This raw Store does not validate or fence a committed TypeGraph schema version. For schema commits and multi-statement schema-managed writes on Cloudflare, use Durable Objects (below), whose SQLite storage exposes an interactive transaction runner.
Important: D1 has no interactive transaction primitive
(D1Database.batch(...) is transactional, but batch-only — not an
interactive runner), so store.transaction() refuses on D1 before invoking
its callback. See
Limitations for details. For a transactional Cloudflare
SQLite store, use Durable Objects (below) instead.
For the same reason, a write guarded by a declared constraint — edge
cardinality other than many, a disjointWith axiom, a shared-scope unique, or
dynamic getOrCreateByEndpoints convergence — is refused on D1 with
CONSTRAINT_WRITE_FENCE_UNSUPPORTED rather than committed unfenced. See
Declared constraints require an interactive transaction.
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.execution.interactiveTransactions: true — no executionProfile hint needed.
Unlike D1, Durable Objects expose an interactive storage transaction runner,
so adapter stores can provide fully atomic store.transaction() and
store.withTransaction() operations.
The runtime authorizer forbids temporary tables, so the same profile reports
capabilities.graphAnalytics.supported: false. Traversal algorithms such as
shortestPath, reachable, and weightedShortestPath automatically use their
inline fallback; temporary-table-only analytics such as
weaklyConnectedComponents throw UnsupportedBackendCapabilityError.
The authorizer also rejects SQLite’s analysis_limit tuning PRAGMA. Statistics
refresh catches that specific authorization error and still runs scoped
ANALYZE; this affects refresh cost only, not query results.
The same profile advertises Cloudflare’s 100-bound-parameter query limit.
TypeGraph uses that hard ceiling for its managed write batches and
recorded-history flushes; capability overrides may lower it but cannot raise
it. Literal .in() and .notIn() query lists are packed into one JSON-bound
parameter, so the list itself does not exhaust the Durable Object budget.
import { drizzle } from "drizzle-orm/durable-sqlite";import { createAdapterStoreWithSchema } from "@nicia-ai/typegraph";import { createSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/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 createAdapterStoreWithSchema(graph, backend);
// Atomic across TypeGraph + the product's own relational tables: await store.transaction(async (tx) => { await tx.nodes.Document.update(documentId, props); if (tx.sqlAvailability !== "available") { throw new Error(`Native transaction unavailable: ${tx.sqlAvailability}`); } const sqlTx = tx.sql; 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);const store = createStore(graph, backend);
if (store.capabilities.execution.interactiveTransactions) { await store.transaction(async (tx) => { /* ... */ });} else { // Handle non-transactional execution}
if (store.capabilities.vector?.supported) { // Vector similarity queries available}store.capabilities is the portable runtime source of truth; adapter authors
can inspect the same object as backend.capabilities. The shape is:
| Field | Meaning |
|---|---|
execution |
Execution boundaries: interactiveTransactions, exact-resource atomicBatch support, and derived unitOfWork |
windowFunctions |
SQL window functions such as ROW_NUMBER() are available |
constraintClaims? |
The backend carries the claim relations that fence declared constraints without a lock (see below) |
durableEdgeMatchIdentity? |
Edge writes persist and atomically arbitrate a schema-declared endpoint/property identity |
graphAnalytics?.{supported,mathFunctions} |
Static support for whole-graph temporary-table iteration, plus availability of deferred transcendental-math algorithms |
vector?.metrics / vector?.indexTypes / vector?.maxDimensions |
Vector strategy capabilities (present once a vector strategy is configured) |
fulltext?.{supported,languages,phraseQueries,prefixQueries,highlighting} |
Fulltext strategy capabilities |
recursiveTraversal?.{supported,reason} |
Whether the engine can compute a bounded transitive closure of a relation in one round trip — a recursive CTE, or a graph-native expansion operator. Absent means supported |
writeFence?.{mechanism,drain} |
How this engine excludes concurrent writers, and how far a caller can drain a table lock — see Write fence declaration |
recordedTimeOwnership? |
Who allocates recorded-time revisions — see Recorded-time ownership |
The former top-level capabilities.transactions override is not interpreted
as an alias. Bundled factories refuse it with LEGACY_CAPABILITY_OVERRIDE,
including for JavaScript and already-compiled callers, because transaction
availability and atomic batching are now independent facts. Move the value to
capabilities.execution.interactiveTransactions; root atomicBatch support
is discovered from the bundled transport and cannot be claimed through factory
overrides. Bundled PostgreSQL transaction factories may expose
atomicBatch: "session" on the exact already-open transaction object. That
declaration is paired with fresh transport and semantic registrations and is
never inherited by an ordinary derived backend.
execution.unitOfWork is derived, never declared by a factory or override:
"interactive" when interactiveTransactions is true, else "batch" when
atomicBatch is not "none" (an HTTP-only driver such as drizzle-orm/neon-http,
which cannot hold an open session but does support a native atomic program),
else "none". Two internal readers key off the "batch" value: the
batch-tier write verdict (resolveBatchWriteVerdict) that produces
BATCH_WRITE_UNSUPPORTED refusals, and the autocommit single-statement
eligibility gate that decides whether a supplied-id singleton create can
fuse its schema fence into one statement. It also exists so any other
caller can tell the three execution shapes apart without re-deriving the
same distinction from interactiveTransactions and atomicBatch
separately.
graphAnalytics.supported describes the backend shape, not mutable PostgreSQL
session state. A hot standby or a role without the database TEMP privilege can
still reject the working-table transaction that the iterative graph algorithms
open: a standby refuses the read-write transaction itself, and a role without
TEMP refuses the CREATE TEMP TABLE inside it. Both refusals reach the caller
as UnsupportedBackendCapabilityError, with the PostgreSQL error retained as
its cause.
Durable edge match identity capability
Section titled “Durable edge match identity capability”capabilities.durableEdgeMatchIdentity: true is a correctness promise. A
custom backend making it must provide all of these guarantees:
- Every edge write carrying
InsertEdgeParams.matchIdentitystores both the name and key with the row. They are either both absent or both present. - A database constraint atomically owns uniqueness over
(graph_id, kind, match_identity_name, match_identity_key). Soft deletion keeps the key; physical hard deletion releases it. commands.execute()handles a durableedge.converge-createas one database decision and returns the authoritativecreatedorfoundrow. Returningunsupportedfails closed withDURABLE_EDGE_MATCH_IDENTITY_COMMAND_UNSUPPORTED; TypeGraph does not fall back to a read-then-write race.- Storage exists before runtime writes. Implement
ensureEdgeMatchIdentityStoragefor privileged schema adoption, or provision the columns, pair constraint, and unique arbiter independently before setting the capability.
insertEdgesDurableBatchReturning is an optional throughput member. When
implemented, every input must carry a durable identity, conflicts are omitted
from the returned rows, and returned rows identify exactly which inputs were
created. Omitting it preserves correctness through per-row authoritative
commands, but loses the set-oriented bulk/import fast path.
findEdgesByHeterogeneousEndpointSet is likewise an optional set-read
optimization. An input carrying opposite requests an exact directed endpoint
pair, not every edge incident to the first endpoint. One call may contain only
incident inputs or only exact-pair inputs; mixing the two modes is refused. A
backend that omits the member retains the exact per-pair fallback.
Validity-end clearing capability
Section titled “Validity-end clearing capability”Custom backends must advertise capabilities.clearValidTo: true only when both
updateNode and updateEdge apply clearValidTo: true by storing SQL NULL in
valid_to. The built-in SQLite and PostgreSQL adapters do. An explicit clear on
a backend without that promise is refused with ConfigurationError code
CLEAR_VALID_TO_UNSUPPORTED before coalescing or writes, so the result
does not depend on whether the target row is already open. Omission still means
preserve; custom backends that do not support clearing remain compatible with
all writes that omit the option.
Recorded-table migration DDL (recordedTableDdl)
Section titled “Recorded-table migration DDL (recordedTableDdl)”GraphBackend.recordedTableDdl is an optional, synchronous callback used only by the offline
timestamp-only recorded-time preview migration. The migration calls it twice, once with temporary
table names and once with the final names, and expects DDL for recordedClock, recordedNodes, and
recordedEdges. The backend owns this callback because table creation, indexes, and named
constraints are dialect-specific and must not pull Drizzle into portable entrypoints.
A custom backend can omit the callback unless it created data in the old preview schema. If
migrateLegacyRecordedTime discovers that schema and the callback is absent, it throws
UnsupportedBackendCapabilityError with details.capability: "recordedTableDdl". When the engine
names primary-key constraints, the temporary and final callback results must either both name the
constraint or both omit it; a one-sided result throws ConfigurationError code
RECORDED_DDL_CONSTRAINT_NAME_MISMATCH.
The callback only describes DDL. It must not execute statements or inspect the catalog, because the migration invokes it inside its transaction. See Migrating Preview Recorded Time for the operator workflow.
Recursive traversal capability
Section titled “Recursive traversal capability”Both bundled backends declare capabilities.recursiveTraversal: { supported: true }. Absent
means supported — mirroring returning, not constraintClaims: every existing custom backend
already runs the six recursive-CTE emission sites unconditionally, so absence meaning unsupported
would refuse traversals that work today.
const capabilities: Partial<BackendCapabilities> = { recursiveTraversal: { supported: false, reason: "engine has no WITH RECURSIVE / equivalent" },};A backend that genuinely lacks the primitive declares { supported: false, reason }. A factory
refuses a contradictory declaration — supported: false with no reason, or supported: true
with a dangling reason — with ConfigurationError details code CAPABILITY_DECLARATION_CONTRADICTION.
Five operations refuse when unsupported: variable-length (traverse) queries, store.subgraph(),
historical identity class reads, identity-expanded historical queries, and the identity
window-ledger read — each throwing ConfigurationError code RECURSIVE_TRAVERSAL_UNSUPPORTED
with details.operation naming the site and details.reason echoing the declaration.
weightedShortestPath is the one exception: on a backend with temporary statements but no
recursion, it falls back to a per-hop predecessor walk instead of refusing, issuing
pathLength + 1 extraction statements for the path a recursive CTE would have returned in one
round trip. The unweighted shortestPath (along with reachable, canReach, and neighbors)
emits no recursive CTE at all — it routes through the iterative working-table or inline path
instead — so it neither refuses nor falls back regardless of this declaration.
Write fence declaration (writeFence)
Section titled “Write fence declaration (writeFence)”TypeGraph serializes a family of writes — Operational Identity’s mutations, and the
TypeGraph-owned recorded-clock allocation behind history / revisionTracking — behind a
per-graph fence rather than trusting the engine’s default isolation. capabilities.writeFence
declares what this backend can provide, as two independent facts, and resolveWriteFencePlan is
the one place that declaration turns into a plan every lock site consumes instead of re-deriving:
const capabilities: Partial<BackendCapabilities> = { writeFence: { mechanism: "advisory", drain: "table-lock" },};mechanism is how the backend excludes concurrent writers. writeFence is a discriminated union on
mechanism, and drain is a field of the "advisory" shape only — "engine-serialized" and
"caller-serialized" declarations carry no drain key at all:
mechanism |
Meaning |
|---|---|
"advisory" |
A keyed pg_advisory_xact_lock-style lock a caller takes explicitly. Needs fenceSql (below) and a drain. |
"engine-serialized" |
The engine serializes writers by construction — SQLite’s single writer slot. No lock statement, no fenceSql, no drain. |
"caller-serialized" |
A deployment-level promise, not an engine fact — see below. No lock statement, no drain; a fenceSql the backend still carries is used only for its isolation-fact read (recorded capture’s isolation guard). |
drain (on mechanism: "advisory" only) is a separate fact: whether a caller that already excluded
other writers can additionally take a relation-wide lock on the table a drain site protects:
drain |
Meaning |
|---|---|
"table-lock" |
Yes — a LOCK TABLE-style statement is available and the drain site takes it. |
"quiescent" |
The resource is already exclusive for some other reason (an advisory lock layered under a deployment’s own caller-serialized promise, for instance), so the drain site takes NO statement — one it does not need rather than one it cannot spell. |
"none" |
Neither — a drain site refuses, naming the drain. |
"engine-serialized" and "caller-serialized" satisfy every drain site unconditionally — a writer
slot and an in-process serialization promise are each already a stronger exclusion than any drain
value could add, so attaching one to either mechanism is refused (see Runtime validation below)
rather than silently ignored.
resolveWriteFencePlan resolves one of four plans:
{ kind: "lock", drain, sql }— take the declared keyed lock (sql, the target’s own spelling), and, whendrain === "table-lock", the table lock a drain site needs.{ kind: "engine-serialized" }— no lock needed; the engine serializes writers by construction.{ kind: "caller-serialized" }— no lock needed; the deployment’s own promise excludes concurrent writers (see below).{ kind: "unfenced" }— no declaration at all. Every fence that guards a read-then-write across statements refuses rather than running unfenced. Only a predicate carried inside the statement it guards degrades, since one statement cannot race itself.
Resolution order: (1) the declared writeFence value, if present; (2) absent, AND the backend was
built by createSqliteBackend / createPostgresBackend — derived from dialect, which is exactly
what every lock site used to compute inline; (3) absent on anything else — unfenced, because an
undeclared custom backend is by definition uncertified and inferring lock support from dialect
alone is the unsound inference this capability replaces.
The two bundled backends resolve exactly these declarations — copy the one matching your engine:
- PostgreSQL:
writeFence: { mechanism: "advisory", drain: "table-lock" } - SQLite:
writeFence: { mechanism: "engine-serialized" }(nodrain: the writer slot already excludes every drain site’s writer, so a drain site under it always takes no statement — the same behaviordrain: "quiescent"describes for"advisory", without adrainfield to spell it)
A backend that declares mechanism: "advisory" also supplies fenceSql: lockTables (only needed
when drain: "table-lock") plus the two composable, no-SELECT forms advisoryLockExpression /
isolationFactExpression a statement embeds inside a larger query it builds itself (see the
schema-write-fence discussion above) — the complete FenceSql bag. resolveWriteFencePlan’s lock
arm derives the standalone-statement forms every ordinary lock site actually calls —
advisoryLock; advisoryLockWithIsolation (the lock plus the session’s isolation-level fact, read
in the same statement it locks in); and isolationFact — from those two expressions, so a backend
author never spells both forms separately. The bundled PostgreSQL spelling is exported as
postgresFenceSql from @nicia-ai/typegraph/adapters/drizzle/postgres — pass it straight through
as fenceSql when wrapping that backend, or supply a custom FenceSql matching a different
engine’s lock syntax. A backend that declares mechanism: "advisory" with a fenceSql missing a
member the resolved mechanism/drain combination needs is refused at construction with details
code WRITE_FENCE_SQL_UNAVAILABLE, naming the missing member; "engine-serialized" and
"caller-serialized" need no fenceSql to take a lock at all.
Runtime validation
Section titled “Runtime validation”TypeScript’s discriminated union only holds a caller who goes through the type checker — a plain
JavaScript backend author, or a value round-tripped through JSON or a config file, can still supply
an unrecognized mechanism string, an unrecognized drain string, or a drain attached to
"engine-serialized" / "caller-serialized". resolveWriteFencePlan validates every declaration —
whether it came from capabilities.writeFence directly or from the first-party dialect fallback —
before shaping a plan from it, and refuses with ConfigurationError details code
WRITE_FENCE_DECLARATION_INVALID, naming the invalid field ("mechanism" or "drain") and, for
an unrecognized value, the accepted list. An unrecognized drain never falls through to behaving
like "quiescent" — it is refused outright, the same as an unrecognized mechanism.
caller-serialized: the promise split into two halves
Section titled “caller-serialized: the promise split into two halves”caller-serialized is for a deployment that knows its database has no other concurrent writer, but
whose engine is neither an advisory-lock engine nor a single-writer one — a PostgreSQL-wire engine
with no working pg_advisory_xact_lock / LOCK TABLE, for example. The promise has two halves,
and TypeGraph only enforces the first:
- In process, TypeGraph enforces it: every root member the backend classifies as mutating —
collection writes,
store.transaction/transactionWithNative, schema commits, identity and contribution maintenance, index materialization, table/DDL provisioning,clearGraph, import, and the raw-SQL members (execute,executeRaw,executeStatement,executeTemporaryStatement) that can carry an arbitrary write — runs through one per-backend serialized queue, so two concurrent calls through one pool cannot race each other. A root write awaited from inside astore.transactioncallback is refused rather than left to deadlock behind the transaction’s own queue slot. - Outside the process, the deployment enforces it: no other client writes to this database
while this backend is open. TypeGraph cannot see or verify that half; declaring
caller-serializedis asserting it.
Adopting an externally owned transaction (store.withTransaction(externalTx), backed by
adoptTransaction) is refused outright on a caller-serialized backend, with ConfigurationError
details code CALLER_SERIALIZED_REFUSES_ADOPTION: an adopted transaction’s lifetime belongs to the
caller, not to this backend’s write-unit queue, so there is no honest way to hold a queue slot open
for it — queuing it would block every other queued write until the caller’s own transaction ends,
and leaving it unqueued would let its writes interleave with the queue’s own, silently breaking the
promise caller-serialized makes. Open the transaction through this backend’s own transaction() /
transactionWithNative() instead, or do not declare caller-serialized on a backend that needs
cross-store adoption.
createPostgresBackend accepts writeFence: { mechanism: "caller-serialized" } — a claim about the
deployment — while still refusing mechanism: "engine-serialized" outright, because that value is
a claim about the engine, which this factory’s own engine does not back.
Constructing Operational Identity, or history: true / revisionTracking: true, against an
unfenced backend is refused immediately at createStore — never mid-flush — with
ConfigurationError details code IDENTITY_REQUIRES_WRITE_FENCE (identity) or
RECORDED_CLOCK_REQUIRES_WRITE_FENCE (recorded-clock allocation), and the refusal message names
the exact declaration line to add.
A lock plan whose drain cannot back a site’s requires: "drain" — drain: "none" — is
refused with details code WRITE_FENCE_UNAVAILABLE, naming details.operation and the drain that
could not be satisfied. "engine-serialized" and "caller-serialized" satisfy either requires
value ("keyed" or "drain") without consulting drain.
The PostgreSQL schema fence refuses too, and it is worth knowing why it is not on the
degradable side. The per-graph advisory lock plus SELECT ... FOR UPDATE a schema commit takes,
and the FOR SHARE a managed write takes on that same row, each fence a read-then-write sequence
that spans statements: commitSchemaVersion reads the active version and then writes the
flip, and a managed write holds its FOR SHARE for the remainder of the transaction so the
version it asserted stays true through the writes that follow. Skipping those locks would not
give a slower-but-correct path; it would assert a version and then let the very change the
assertion was checking for land before the write. So a PostgreSQL-dialect backend that resolves
unfenced is refused at the schema commit with WRITE_FENCE_UNAVAILABLE, naming the operation.
The one part that does degrade is the fence folded into a managed insert’s own statement. That predicate is evaluated inside the INSERT that depends on it, and one statement cannot race itself: with no locking clause the fence subquery still yields no row when the expected version is no longer active, so the INSERT still writes nothing. This is how SQLite has always run the path, on the strength of its writer slot.
This matters for a PostgreSQL-wire engine that implements neither pg_advisory_xact_lock nor the
FOR UPDATE / FOR SHARE clauses, and whose engine merges concurrent transactions rather than
serializing them.
writeFence has no arm for “no exclusion mechanism at all” — every mechanism value claims
something real. An engine with neither locks nor a writer slot nor a caller promise to make has
nothing honest to declare, and unfenced is how that shows up downstream — but neither bundled
factory will hand you that backend. createPostgresBackend and createSqliteBackend both build on
createSqlBackend, which refuses at construction, with ConfigurationError details code
ENGINE_PROFILE_REQUIRES_WRITE_FENCE_DECLARATION, when the resolved capabilities carry no
writeFence at all — including capabilities: { writeFence: undefined } passed to either factory,
which no longer builds a backend the way it once did:
createPostgresBackend(db, { capabilities: { writeFence: undefined },});// throws ConfigurationError: ENGINE_PROFILE_REQUIRES_WRITE_FENCE_DECLARATIONunfenced is reachable only outside that gate: a hand-assembled GraphBackend that never goes
through createSqlBackend, or a custom SqlEngineProfile whose declaredCapabilities.writeFence
some other override clears before it reaches a call site — never through either bundled factory.
Getting there is a way of admitting the engine truly has nothing to declare; TypeGraph then refuses
a schema-managed store built on it at createStore, naming the missing capability, rather than
running a fence the engine cannot enforce.
If the deployment instead knows it is the only writer of this database — a pool clamped to one
connection, or a single-writer topology otherwise enforced outside TypeGraph — declare
writeFence: { mechanism: "caller-serialized" } instead (see above): that is
the honest way to spell a deployment convention. Do not reach for mechanism: "engine-serialized"
for the same purpose — that declaration means the engine serializes writers by construction, and
a deployment convention is not a construction. createPostgresBackend refuses that particular
claim outright for this reason.
Recorded-time ownership (recordedTimeOwnership)
Section titled “Recorded-time ownership (recordedTimeOwnership)”capabilities.recordedTimeOwnership names who allocates recorded-time revisions. Absent means
"typegraph-relations" — today’s behavior for every existing backend: TypeGraph owns a clock
row and performs the read/advance/write that the write fence serializes.
Declaring "engine-native" together with history: true or revisionTracking: true is refused
at construction with ConfigurationError details code
ENGINE_NATIVE_RECORDED_TIME_NOT_IMPLEMENTED — the engine-native read/write path does not exist
yet, so admitting the declaration would move the refusal from construction to mid-flush instead.
This refusal is independent of the write-fence plan above: it fires whether the same backend is
fenced or unfenced, because it is about the missing read/write path, not about locking. Declaring
"engine-native" without history / revisionTracking constructs without incident — the
declaration has no consumer to refuse until one exists.
Capability bundles
Section titled “Capability bundles”A capability bundle groups a set of GraphBackend members that one operation family needs
together, with one verdict resolver and one member accessor, so a caller never re-derives “does
this backend support X” from a scattered undefined check. Six pilot bundles ship in this
release:
| Bundle | Kind | Disposition |
|---|---|---|
claims |
gated | Bidirectional cross-check between the constraintClaims declaration and the core members; disagreement in either direction refuses with CONSTRAINT_CLAIM_SURFACE_MISMATCH |
statementExecution |
gated | Core executeStatement absent refuses with IDENTITY_REQUIRES_STATEMENT_EXECUTION |
recordedRevisionOrigins |
gated | Core ensureRevisionOriginsTable absent refuses with the operation’s own typed error |
batchPointRead |
graduated | getNodes absent falls back to per-id getNode; getEdges absent falls back to per-id getEdge |
uniqueSidecarBatch |
graduated | insertUniqueBatch absent falls back to issueClaimsIndividually; checkUniqueBatch absent falls back to a per-key loop; hardDeleteUniquesByNodeIds absent refuses with the operation’s own typed error |
contributionHealth |
graduated | verifyContributions / repairContributions / rebuildContribution absent each refuse with the operation’s own typed error; probeContributions absent falls back to { entries: [] } |
The port-mismatch rule that governs every bundle’s member accessor is keyed to the disposition,
not blanket: a refuse-disposition row whose backend object cannot actually reach the member
throws that bundle’s own portSurfaceCode (CONSTRAINT_CLAIM_SURFACE_MISMATCH for claims,
BUNDLE_PORT_SURFACE_MISMATCH for the other five); a fallback-disposition row whose port cannot
reach the member takes its declared fallback instead of throwing — the verdict said the member
was there, the object it binds against says otherwise, and a fallback row is defined to degrade
rather than assert.
This bundle model ships for six of the twenty-one member-bearing operation families measured in this workstream; the remaining fifteen are a named follow-up workstream, not a silent gap — their members keep working exactly as before, unbundled, with an access-count ceiling that prevents new scattered checks from accumulating ahead of that follow-up.
A backend author does not need to do anything for these six bundles today: both bundled backends
already carry every core member each bundle’s dialects scope requires. The atomic transport
conformance runner is the foundation for certifying a third-party backend: the author supplies
engine-specific statements, state observers, and exact-root provenance checks, while the runner
asserts the shared transport contract. Bundle verdicts remain a separate check against the declared
capabilities and the object the calls actually execute on. A backend must therefore make every
declared capability (constraintClaims, contributions, and execution support) truthful about
what the active backend object implements, not just which fields it sets.
Run the conformance fixture in the custom backend’s own test suite, then pair the earned declaration with the exact root transport in its factory:
import { decorateBackend, registerAtomicMutationPrograms, registerAtomicSqlProgram, runAtomicMutationProgramConformance, runAtomicTransportConformance,} from "@nicia-ai/typegraph/backend";
const backend = createCustomBackend({ execution: { interactiveTransactions: false, atomicBatch: "root", },});registerAtomicSqlProgram(backend, { executeAtomicBatch });const authorCreatedWrapper = decorateBackend(backend, {});
await runAtomicTransportConformance({ ...transportCases, backend, derivedBackends: [authorCreatedWrapper], executeAtomicBatch,});
registerAtomicMutationPrograms(backend, mutationPrograms);const semanticCases = buildSemanticCases({ backend });
await runAtomicMutationProgramConformance({ backend, derivedBackends: [authorCreatedWrapper], equal: deepEqual, cases: semanticCases,});Transport registration is exact-resource evidence only: a derived backend does
not inherit it, and a second registration on the same object is refused rather
than replacing the function production uses. A bundled PostgreSQL transaction
session earns a separate registration bound to its pinned client; it does not
inherit the root’s registration.
Create wrappers with the exported decorateBackend() seam so the runner can
verify their lineage back to the registered root instead of accepting an
unrelated object as derivation evidence. The conformance fixture’s mandatory
provenance checks prove registration, lineage, derived isolation, and—when
applicable—transaction isolation against the real objects supplied by the
backend author. A non-interactive root reports the
transaction-isolation check as skipped rather than claiming evidence it could
not obtain. Transport registration certifies mechanics, not graph semantics, and
therefore does not by itself opt a custom backend into any Store mutation
program.
The separate registerAtomicMutationPrograms() call is the semantic boundary:
each member declares one complete TypeGraph mutation family implemented by that
exact backend resource. Omitted families retain the portable path, and an empty profile or
a profile registered before its atomic transport is refused with
ConfigurationError.
The semantic executors must preserve the same schema fence, validation, side-effect, refusal classification, rollback, postimage correlation, result ordering, and bind-ceiling contracts as the bundled implementation. Registering one family is not evidence for another. Derived and projected backends inherit neither registration. An exact transaction session must be registered independently before Store code can dispatch through it.
runAtomicMutationProgramConformance() is the executable semantic boundary.
For every reachable positive-limit variant in mutationPrograms, the fixture supplies
three real Store-level cases:
- an ordered success whose return value and independently read committed state both match their oracles;
- a stale-schema-fence refusal that leaves the database unchanged; and
- a family-specific typed refusal that either rolls back every sibling write after native dispatch or explicitly refuses before dispatch without writing.
The runner resolves the profile from backend; it does not accept a detached
profile description, caller-supplied provenance verdict, or fixture-owned
dispatch counter. Before any fixture preparation can write, it validates the
complete case inventory and probes the author’s actual derived backend objects.
It observes dispatch inside the exact registered executors and therefore refuses
a success that silently used the portable fallback,
a case bound to a different family claim, a missing or duplicate family case,
and a case that claims an unregistered family. A zero entry limit is an honest
opt-out and does not require an unreachable case. mutateEdges has separate
resolvedSet and durableConvergence variants because proving one does not
prove the other.
Every semantic case identifies the exact backend its callbacks use. The
runner checks that binding and the registered profile identity before any
preparation, again between preparation and execution, and after execution, so
a pre-dispatch refusal or a mid-run registry replacement cannot borrow another
root’s certificate. The fixture callbacks should invoke public Store methods and inspect committed
rows through an independent database read. Supply at least one real wrapper or
derived backend created with decorateBackend(); the runner does not manufacture
a projection and mistake that tautology for author evidence. Do not instrument
or replace the registered executors—the runner owns dispatch evidence. Run
conformance with exclusive use of that exact root: unrelated same-variant writes
during the observation window cannot be distinguished from fixture traffic.
Mark each semantic refusal’s dispatch as "required" or "pre-dispatch"
according to the Store contract, and do not use executor return rows as the
state oracle. Stale-fence cases always require native dispatch regardless of a
fixture value supplied by untyped JavaScript. Match
Store-level typed errors rather than raw driver sentinels. The runner is
framework-agnostic, so the same fixture runs in the custom backend’s own test
suite. Pair it with the shared cross-backend Store integration suite; transport
conformance alone cannot prove graph semantics.
The profile is family-scoped:
| Member | Store operations authorized |
|---|---|
createNodes / createEdges |
Eligible direct bulkInsert() and bulkCreate() programs |
replaceNodes |
Eligible complete-document nodes.bulkReplaceById() programs |
deleteNodes / deleteEdges |
Eligible direct bulkDelete() programs |
updateNodes / updateEdges |
Eligible resolved update-only sets |
mutateNodes / mutateEdges |
Eligible mixed create/update sets; the edge family also owns durable endpoint convergence |
Executor limits such as maxEntries, replaceNodes.maxEntries.plain,
replaceNodes.maxEntries.claimed,
createNodes.claimSupport.maxInputCostPerEntry, and the two edge mutation
ceilings are part of the registration contract and must be nonnegative
integers; zero honestly declares that the backend’s bind budget cannot admit
one member of that shape. TypeGraph validates those declarations before
publishing the exact-root profile. claimSupport.families explicitly
advertises uniqueness and/or disjointness; an empty list with a zero bound
honestly opts out of all claim work. The Store calls the exported
atomicNodeClaimInputCost() owner for each member and refuses the native path
when its complete normalized claim set exceeds the executor’s declared bound.
Custom executors must use that same helper instead of reproducing its
dialect-reviewed bind formula. deleteNodes.releasedClaimFamilies similarly
declares which owner-side claim cleanup the delete program proves.
Bundled replacement executors also expose an accepts(entries) pre-dispatch
proof. It packs prepared members with the same bind-weighted planner used by
execution, so claimed batches are admitted by their actual work instead of an
unrelated fixed 32-entry ceiling; false is an explicit no-SQL fallback
verdict. Custom executors may provide the same exact admission seam when one
claimed-member ceiling would be needlessly pessimistic.
replaceNodes.releasedClaimFamilies declares which previous owner claims the
replacement releases before acquiring its complete postimage claims; the Store
does not infer that proof from claimSupport. Node
create/update/mutation executors advertise derived-storage support separately
through projectionSupport.families; omission or an empty list honestly opts
out, and the Store never infers projection safety from transport registration
alone. The supported families are fulltext and embedding.
On a transactionless root, dedicated
update-only and mixed mutation executors are independently reachable Store
families even when their entry ceilings are equal, so each requires its own
conformance evidence. On an interactive root, the collection-level
read/partition/write unit moves into a transaction and exact-root registration
does not follow; the root conformance inventory therefore excludes the mixed
variants while continuing to require direct create, delete, update, and durable
convergence evidence. Bundled PostgreSQL binds the same reviewed lowering to the
exact transaction session and exercises the mixed node and edge variants against
a real engine, including typed refusal rollback. The same session profile
registers replaceNodes, so a caller-owned PostgreSQL transaction keeps blind
replacement inside its savepoint-backed atomic program.
An exact atomicBatch: "session" conformance fixture includes those mixed
variants even though interactiveTransactions is true; nested-transaction
isolation is reported as inapplicable because the fixture resource is already
the open transaction. The transport runner accepts the same exact-session
resource and certifies its ordered slots, parameter preservation, rollback,
and empty-program behavior. Generic derived session objects still lose both
the declaration and the identity-bound registrations.
Backend authors implementing edge
refusal paths use the exported
AtomicEdgeBatchEndpointRefusalError,
AtomicEdgeBatchCardinalityRefusalError,
AtomicEdgeConvergenceTombstoneRefusalError, and
AtomicEdgeDeleteIdentityRefusalError signals; restricted node deletion uses
AtomicNodeDeleteRestrictedRefusalError. This preserves the Store’s existing
typed diagnostic classification rather than exposing driver-specific sentinel
errors.
Execution support is intentionally not collapsed into one ordered “tier.” An interactive transaction and an exact-root atomic batch are independent facts: a backend may provide either, both, or neither. Each Store operation selects the boundary its own semantics require instead of treating one mechanism as a universal substitute for the other.
Declared constraints require an interactive transaction
Section titled “Declared constraints require an interactive transaction”A constrained write — one whose correctness rests on a check-then-write that
no database key repeats at write time — runs its probe and its write under one
per-graph mutual exclusion. That fence is a transaction-scoped construct on both
dialects: SQLite’s BEGIN IMMEDIATE writer slot, PostgreSQL’s
pg_advisory_xact_lock (which outside a transaction is taken and dropped inside
its own implicit single-statement one, excluding nothing). A backend reporting
capabilities.execution.interactiveTransactions: false can supply neither, so such a write is
refused rather than run unfenced — a constraint enforced only when nothing
races is the defect the fence exists to close.
The refusal is a ConfigurationError with details.code
CONSTRAINT_WRITE_FENCE_UNSUPPORTED, and details.constraint naming which
class needed the fence, because the way forward differs per class:
details.constraint |
The write that needs the fence | Way forward without a transactional backend |
|---|---|---|
edgeCardinality |
Creating or resurrecting an edge whose cardinality is one, unique, or oneActive |
Declare the edge cardinality: "many" and enforce the limit in application code |
edgeMatchKeyConvergence |
getOrCreateByEndpoints using an undeclared dynamic matchOn key |
Declare the edge registration’s durable matchIdentity, or use create with a caller-chosen id |
nodeDisjointness |
Creating a node under a kind that participates in a disjointWith axiom |
Drop the axiom and keep ids distinct across those kinds yourself |
nodeUniquenessClaim |
Updating or resurrecting a node whose kind declares any unique constraint, of any scope — a transition reserves the new key before the row write it gates, and only a transaction can undo the pair together | Drop the constraint, or run updates on a transactional backend. Plain creates under a scope: "kind" unique are unaffected: their claim follows the row |
nodeUniquenessScope |
Creating or updating a node under a scope: "kindWithSubClasses" unique that actually expands past the node’s own kind |
Scope the constraint to "kind", which the uniques primary key enforces on its own |
importGraph / importGraphStream is refused on the same backends whenever any
node kind of the graph owes a claim ahead of its row — that is, declares any
unique constraint or has a disjoint partner — or any edge kind is non-many.
The import writes both creates and updates, so the widest of those placements is
what decides it.
This affects Cloudflare D1, drizzle-orm/neon-http, and any SQLite
backend built with transactionMode: "none". Durable Objects are unaffected —
do-sqlite reports capabilities.execution.interactiveTransactions: true and fences normally.
Unconstrained writes on those backends are untouched and keep working exactly as
before: a cardinality: "many" edge created, updated and deleted; any node
delete, including one whose kind participates in a disjointness axiom (a delete
re-derives no cross-kind verdict); a node whose uniques are all scope: "kind";
and an undeclared getOrCreateByEndpoints that finds an existing edge in the default
ifExists: "return" mode, or resurrects a many one — that resurrection is an
id-keyed UPDATE that re-derives nothing. With coalesceUnchangedUpserts
enabled, confirming that a single ifExists: "update" endpoint replay is
unchanged requires the endpoint match-key convergence fence and therefore
refuses on these backends. Outside the native durable-convergence envelope,
the bulk getOrCreateByEndpoints form returns an all-live default-"return"
batch from one set-oriented root read because that outcome writes nothing.
Inside the native envelope, the authoritative upsert runs first; it preserves
the logical "found" outcome in one exchange but may take incumbent-row locks
and produce write amplification. If any member may write, the whole batch
retains that refusal on transactionless roots unless it matches the narrow native
durable-convergence envelope: schema-declared
matchIdentity, cardinality: "many", declared match fields, default
ifExists: "return", and no temporal mutation. That eligible form is one
closed atomic exchange; dynamic match fields, update mode, constrained
cardinality, temporal options, and all transaction-scoped or derived roots
retain the refusal or fallback path required by their contracts.
An otherwise eligible tombstoned winner cannot use the native path: the native
attempt rolls back and transactionless convergence refuses with the typed
CONSTRAINT_WRITE_FENCE_UNSUPPORTED (edgeMatchKeyConvergence) error. Use a
transaction-capable backend when schema-aware resurrection is required.
Claim relations, and what they do not promise
Section titled “Claim relations, and what they do not promise”Underneath the lock, a declared constraint is also reserved in a claim
relation whose primary key admits one live claimant per axis: uniques (for
uniqueness scopes and disjointWith pairs) and typegraph_edge_claims (for
cardinality: "one" | "unique" | "oneActive"). Both bundled backends carry them
and report capabilities.constraintClaims: true. The claim is what makes those
constraints hold for TypeGraph writers that hold no per-graph lock at all —
importGraph is the one in the box. The protocol is application-maintained:
raw SQL that writes only nodes or edges bypasses the corresponding claim
write and can violate the declaration. An out-of-band writer is fenced only if
it participates in the same claim protocol in the same transaction.
Three properties of that mechanism are worth knowing before you rely on it:
- A claim row’s lock is held to the end of the transaction, including on
refusal. A caller that catches a typed constraint error and keeps going —
import’s per-row recovery, or your own
try/catchinsidestore.transaction— still holds the lock on the row it was refused at, and any other writer of that axis waits until the transaction ends. This is inherent to every row-lock fence, not specific to this one. - Above READ COMMITTED, PostgreSQL reports a serialization failure instead of
the typed error. At
REPEATABLE READorSERIALIZABLE,INSERT … ON CONFLICT DO UPDATEraises40001rather than resolving the conflict, so the losing writer sees a serialization failure to retry rather thanUniquenessError. SQLite has no such mode. This is unchanged from earlier versions, which already reserved single-kind uniqueness through the same statement. - Pre-existing violations are neither repaired nor refused at boot. A
database that already held two live claimants of one axis before the claim
relations existed keeps holding them; the next write that touches that axis is
refused with the ordinary typed error naming the incumbent.
store.verifyConstraintFences()is the read-only diagnostic that makes that state legible ahead of time:
for (const violation of await store.verifyConstraintFences()) { // violation.target names the claim row two claimants contend for console.warn(violation.family, violation.target.axis, violation.target.key);}It reports one entry per contended axis — nodeUniqueness and
nodeDisjointness carry the conflicting owners (each a concrete_kind /
node_id pair, because ids are unique only per kind), edgeCardinality carries
the conflicting edgeIds. It reads the nodes, edges and uniques relations, so
it finds violations that predate the claim tables; it writes nothing, and it
repairs nothing — choosing which claimant keeps the axis is a data-loss decision
that stays with you.
SQLite ↔ PostgreSQL parity
Section titled “SQLite ↔ PostgreSQL parity”The query language is fully portable between SQLite and PostgreSQL. Predicates (comparison, string/ILIKE,
null, between, array, object, JSON-path), fixed and variable-length (recursive) traversals, aggregates
(count/sum/avg/min/max with groupBy/having), set operations (UNION/UNION ALL/INTERSECT/EXCEPT,
including traversal, subquery, GROUP BY/HAVING, and per-leaf ORDER BY/LIMIT/OFFSET leaves), ordering with
NULLS FIRST/LAST, cursor pagination, temporal queries, and the fulltext query modes (websearch, phrase,
plain, raw) all behave identically. A query you write against one backend compiles and runs the same way on the
other.
The remaining differences are engine and runtime capability gaps — they stem from what each database or hosted authorizer implements, not from TypeGraph choosing separate query semantics per backend:
| Capability | SQLite | PostgreSQL | Behavior on the unsupported side |
|---|---|---|---|
| Whole-graph temporary-table analytics | ✓ standard connections / ✗ D1 and Durable Objects | ✓ connection-based drivers / ✗ neon-http |
Throws UnsupportedBackendCapabilityError; traversal algorithms with an inline engine fall back automatically |
Vector metric inner_product |
✗ | ✓ | Rejected at compile time on SQLite (sqlite-vec/libsql-native expose cosine + l2; pgvector adds inner_product) |
Vector index type ivfflat |
✗ | ✓ | Index declaration is skipped on SQLite (indexTypes: hnsw/none vs hnsw/ivfflat/none) |
| Filtered approximate search guarantees a full page | ✓ sqlite-vec / ✗ libsql-native |
✗ (pgvector recovers, but is bounded) |
Only sqlite-vec guarantees it; the others can return fewer than limit rows under heavy filtering — see below |
Per-query fulltext language override |
✗ | ✓ | Throws on SQLite — FTS5’s tokenizer is fixed at table-create time; tsvector accepts a regconfig per query |
HNSW efSearch query tuning |
✗ | ✓ transactional HNSW drivers | Refused, never ignored: UnsupportedBackendCapabilityError with details.capability vector.searchFrontierTuning on any SQLite backend (vector and hybrid alike — neither sqlite-vec’s vec0 KNN nor libsql-native’s DiskANN has a per-search frontier), and on transaction-less Postgres or a non-HNSW slot |
| Bounded planner-statistics sampling | ✓ standard connections / ✗ D1 and Durable Objects | Native ANALYZE sampling |
Restricted SQLite skips analysis_limit but still attempts scoped ANALYZE. Performance only — same results |
| TypeGraph Identity Profile | ✓ transactional drivers | ✓ transactional drivers | Enabled graphs fail fast on non-atomic drivers; identity-disabled graphs retain their ordinary path |
Constraint claim relations (capabilities.constraintClaims) |
✓ | ✓ | Identical relations and identical statements on both dialects. A third-party backend that omits them declares constraintClaims absent and keeps the per-graph lock as its only fence |
Durable edge match identity (capabilities.durableEdgeMatchIdentity) |
✓ bundled adapters | ✓ bundled adapters | Both dialects persist the same canonical key and use a unique database arbiter. A custom backend must satisfy the full capability contract above or leave the capability absent |
| Managed node projection fusion | ✓ registered atomic bulk programs; singleton fallback | ✓ registered atomic bulk programs; singleton create fusion | Eligible node bulk creates and resolved updates group fulltext/vector transitions into the same atomic program as their row mutations on both dialects. PostgreSQL additionally fuses an eligible singleton generated-ID create into one SQL statement when every active strategy supplies an inserted-node builder |
Managed node claim fusion (capabilities.atomicNodeInsertClaims) |
✗ portable transactional fallback | ✓ PostgreSQL/PGlite | SQLite keeps claim acquisition and insertion in the portable transaction. PostgreSQL transaction receivers fuse supported claim plans; a root non-transactional receiver is limited to exactly one generated-id, same-kind uniqueness claim with no other side effects |
| Managed edge cardinality fusion | ✗ portable transactional fallback | ✓ PostgreSQL/PGlite transaction receivers | SQLite keeps its guarded claim and edge insert in the portable transaction. PostgreSQL can combine endpoint liveness, one cardinality claim, and the insert in one statement after any required graph lock |
Atomic SQL transport (capabilities.execution.atomicBatch) |
✓ on certified D1/libSQL roots; otherwise none |
✓ on bundled recognized PostgreSQL drivers, including neon-http | root means the exact backend owns the atomic boundary; session means the exact object is already bound to an open transaction and the outer transaction owns commit/rollback. Both require identity-keyed executor registration. Neon HTTP uses its native transaction batch; session-capable pg, postgres-js, neon-serverless, and PGlite drivers can execute programs on one pinned Drizzle transaction. Unrecognized drivers remain none. A custom backend must pass the framework-agnostic conformance runner before opting in; omitted support keeps the portable path |
| Eligible registered managed writes | ✓ bundled SQLite roots, including D1 and libSQL | ✓ bundled PostgreSQL roots, including neon-http | Eligible singleton generated-ID nodes and cardinality: "many" edges use one authoritative create statement. Eligible node updates may carry fulltext/vector replacements; unconstrained non-durable-identity edge updates, direct edge deletes, and plain restricted node deletes use one authoritative read/gate plus one registered atomic mutation. Generated-, caller-, or mixed-ID node bulkInsert/bulkCreate batches compose supported multi-claim/cross-scope claim sets with projections in one schema-fenced native program; direct edge programs also maintain durable match identity and cardinality claims. Direct edge bulkDelete and plain restricted node bulkDelete use the same mutation profile. Eligible mixed bulkUpsertById sets, including node projections, use the profile on serverless roots and on exact bundled PostgreSQL transaction sessions; a generic derived backend still loses the evidence. A custom backend may opt in per family only after registering its exact transport and semantic executor. Unregistered or otherwise ineligible families, projected/identity-enabled node deletes, over-budget claimed members, cascade/disconnect deletes, and other managed writes retain the existing path |
| Typed constraint error above READ COMMITTED | n/a (no such isolation mode) | ✗ at REPEATABLE READ / SERIALIZABLE |
PostgreSQL raises 40001 from the claim’s upsert instead of resolving the conflict, so the loser retries a serialization failure rather than reading UniquenessError |
| Claim row lock released before end of transaction | ✗ | ✗ | Held to commit/rollback on both dialects, refusal included — a caller that catches a constraint error blocks other writers of that axis for the rest of its transaction |
Recursive traversal (capabilities.recursiveTraversal) |
✓ | ✓ | Identical on both bundled backends. A third-party backend declaring { supported: false, reason } refuses the five recursion-dependent operations with ConfigurationError code RECURSIVE_TRAVERSAL_UNSUPPORTED; weightedShortestPath degrades to a predecessor walk instead — see above. Unweighted shortestPath is unaffected — it never emits a recursive CTE |
Write fence (capabilities.writeFence) |
✓ engine-serialized (single writer slot) |
✓ lock (advisory + table locks) |
Identical guarantee, different mechanism. A custom backend that declares no writeFence resolves unfenced and is refused at construction for Operational Identity or TypeGraph-owned recorded-clock allocation |
Recorded-time ownership (capabilities.recordedTimeOwnership) |
"typegraph-relations" (default) |
"typegraph-relations" (default) |
Both bundled backends own the clock today. "engine-native" is refused at construction as an interim measure whenever it is combined with history/revisionTracking, on either dialect |
Capability bundles (CAPABILITY_BUNDLES) |
Identical | Identical | Both bundled backends implement every pilot bundle’s core/extra members on both dialects it scopes to. A third-party backend with a port gap refuses (gated core, or a refuse-disposition extra) or degrades (a fallback-disposition extra) per that bundle’s own registry row |
Identity support also has a driver dimension inside each dialect:
| Driver | Atomic identity support | Behavior |
|---|---|---|
| Managed SQLite, libSQL, Durable Objects | ✓ | Full profile |
PostgreSQL node-postgres, postgres-js, neon-serverless, PGlite |
✓ | Full profile; identity-affecting writes serialize per graph, limiting each graph to one identity writer at a time |
| Cloudflare D1 | ✗ | Enabled graphs fail at store construction with ConfigurationError details code IDENTITY_REQUIRES_ATOMIC_BACKEND |
drizzle-orm/neon-http |
✗ | Same fail-fast error; identity-disabled graphs retain the ordinary single-statement path |
Filtered approximate search
Section titled “Filtered approximate search”Every approximate (ANN) vector search carries at least one row filter: the liveness predicate that hides
soft-deleted and out-of-validity rows. A .where(...) predicate narrows it further. Engines differ in where they
apply that filter relative to the index traversal, which decides whether a page can come back short. Read it from
backend.capabilities.vector.filteredApproximateSearch:
const filtered = backend.capabilities.vector?.filteredApproximateSearch;if (filtered?.guaranteesFullPage !== true) { // An approximate search here may return fewer than `limit` rows.}Check guaranteesFullPage, not mode. mode names the mechanism the strategy asks the engine for; only
guaranteesFullPage tells you whether a short page is possible.
mode |
Strategy | guaranteesFullPage |
Meaning |
|---|---|---|---|
"filter-pushdown" |
sqlite-vec |
true |
The filter constrains the vec0 KNN candidate set itself. limit matching rows come back whenever limit exist. |
"iterative-scan" |
pgvector |
false |
The index is re-entered for more candidates (hnsw.iterative_scan / ivfflat.iterative_scan, applied automatically on pgvector ≥ 0.8). Much better recall than a post-filter, but not a guarantee: the scan stops at hnsw.max_scan_tuples / ivfflat.max_probes. And on pgvector < 0.8 there is no iterative scan at all — the backend detects that, warns once, and the search stays ef_search-bounded. |
"post-filter" |
libsql-native |
false |
DiskANN’s vector_top_k is a table function with no filter pushdown and no way to re-enter the index. TypeGraph over-fetches 4 × (limit + offset) neighbors and filters afterwards, so once more than that headroom is filtered out the search silently returns fewer than limit rows while more matches exist. |
Heavy tombstone drift — routine in a temporal store — is what turns a bounded search from a theoretical caveat into
a short page. When a full page matters, use an exact search (approximate: false), which scans and so applies the
filter to every row; or declare the field’s index as "none" so it is always brute-forced.
Vector and fulltext capabilities are populated from the configured strategy, so the matrix above reflects the
bundled strategies (sqlite-vec/libsql-native/pgvector, fts5/tsvector). A custom strategy advertising
different metrics/indexTypes/filteredApproximateSearch/searchFrontierTuning shifts these rows accordingly —
always check backend.capabilities at runtime rather than hard-coding the dialect.
searchFrontierTuning is required on a vector strategy’s capabilities, so a strategy must state whether it has a
per-search ANN frontier knob rather than inheriting silence. It is a discriminated union: { tunable: true, parameter, indexType, requiresTransactionScope } names the engine parameter efSearch maps to (pgvector: hnsw.ef_search, on
an hnsw slot, needing a transaction to scope it), while { tunable: false, reason } names why the engine has no such
knob and is what makes efSearch a typed refusal there. A hand-written strategy that omits the field no longer
compiles.
Both bundled backends advertise windowFunctions: true. Vector, fulltext, and hybrid relevance-ranking
queries use ROW_NUMBER() internally and throw ConfigurationError before SQL generation if a custom backend profile
sets windowFunctions: false — there the window output is the result (the relevance k-cutoff / rank ordinal), so
there is no correct fallback.
bulkFindByIndex({ limitPerInput }) also uses ROW_NUMBER() when available, but it does not throw on a
windowless profile: the per-input cap is a transfer optimization with identical row semantics either way, so it
degrades gracefully — fetching all matching ids and capping per group in application code. The unbounded
bulkFindByIndex path needs no window and is always available.
Connection Management
Section titled “Connection Management”Connection ownership follows the entrypoint:
- Managed Store factories (
/sqlite/localand/postgres/pglite) own the connection and provisioned resources.await store.close()releases them. - Owned local backend factories (
createLocalSqliteBackendandcreateLocalPgliteBackend) also own their resources. A Store delegatesclose()to its backend, soawait store.close()releases them. - Bring-your-own adapter factories (
createSqliteBackend,createPostgresBackend, andcreateLibsqlBackend) leave connection ownership with the caller. Their Store’sclose()does not close the supplied client or pool.
When you bring your own connection, 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();});Here store.close() leaves sqlite open because the application supplied the
connection. Close the driver or pool through its own API.
Serialized connections
Section titled “Serialized connections”Some drivers run every statement through one connection. Two long-lived interchange streams cannot share such a connection — an export snapshot holds a read transaction for the whole stream while an import writes one per chunk — so TypeGraph refuses the second one with a typed error instead of letting it hang (see Interchange serialized-connection guard codes).
Recognizing a serialized connection means recognizing the driver, from the shape of the client object. That is deliberately conservative: a driver TypeGraph cannot positively identify is left unmarked, because refusing a pooled connection would refuse work that succeeds.
| Driver / configuration | Detected | Notes |
|---|---|---|
better-sqlite3, bun:sqlite, sql.js, local libSQL (file: / :memory:), Durable Object storage |
✓ automatic | One handle, one connection |
| PGlite | ✓ automatic | One in-process WASM connection |
Bare pg / neon-serverless Client, a checked-out PoolClient |
✓ automatic | One owned socket |
pg Pool capped at one ({ max: 1 }, { max: "1" }, { poolSize: "1" }) |
✓ automatic | pg-pool does not coerce the cap, so the string forms are the same one-connection pool |
postgres-js capped at one ({ max: 1 }, ?max=1, PGMAX=1) |
✓ automatic | Same reasoning on the postgres-js side |
Default-size pools, neon-http, D1, RDS Data API, remote libSQL (http / ws) |
— deliberately not | Each statement gets an independent connection; refusing would refuse work that succeeds |
expo-sqlite, op-sqlite, sqlite-proxy, pg-proxy, a bespoke adapter |
✗ declare it | Serialized in fact, but the client exposes no shape TypeGraph can attribute to a known driver |
Bun SQL (Postgres) at { max: 1 } |
✗ declare it | The cap is readable, but nothing identifies the driver, and a cap on an unknown client is not evidence |
postgres-js with a non-numeric string cap other than one, e.g. ?max=5 |
✗ declare it | Opens exactly one connection today only because postgres-js does not coerce the value — marking it would encode an upstream bug that will one day be fixed |
For the rows marked declare it, tell TypeGraph what it cannot see. The
option is on createSqliteBackend and createPostgresBackend — the two
factories that resolve it. The batteries-included wrappers
(createLibsqlBackend, createLocalSqliteBackend, createLocalPgliteBackend)
do not take it, because each already detects its own connection.
const sql = postgres(process.env.DATABASE_URL + "?max=5");
const backend = createPostgresBackend(drizzle(sql), { // This client really does run every statement on one connection. serializedResource: { mode: "shared", resource: sql },});Two backends that name the same object are one serialized resource, exactly
as two wrappers over a detected client are. Naming a different object than the
one TypeGraph detected is refused with a ConfigurationError
(details.reason: "serialized-resource-conflict") rather than silently
preferred: two wrappers over one connection given two different sentinels would
stop being seen as a pair, which is the failure the guard exists to prevent.
The refusal names each side by constructor (details.declaredKind /
details.detectedKind) instead of carrying the two handles, because details
is what toLogString() serializes and a driver handle there would log whatever
that driver stores — a pg.Pool keeps its connectionString.
The reverse declaration escapes a detection that is wrong for your topology:
const backend = createSqliteBackend(db, { serializedResource: { mode: "independent" },});Scope. { mode: "independent" } lifts the shared-resource refusal between
two distinct backend objects. It does not lift the object-identity refusal, under
which one SQLite backend exporting into itself is refused with
INTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOT. That one is a fact about a single
handle holding a single open snapshot transaction, not a claim about connection
topology, so no declaration can make it false — pass a second backend instead.
That surviving refusal is SQLite-only, so on PostgreSQL the declaration lifts
the refusal for one backend exporting into itself as well: a client that hands
out independent connections — which is exactly what the declaration claims —
runs the snapshot and the writes it contends with on different ones.
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 adopts release-added deployment-wide base storage on pre-provisioned databases, even when the persisted graph schema is unchanged. The first adoption creates or repairs the graph-template and edge match-identity storage, then stamps a version marker; a warm base-schema check is oneSELECTwith no base-adoption DDL. It also durably materializes strategy-owned runtime storage — both fulltext and eachembedding()field’s per-(kind, field)vector table, plus a durable marker for each. It also brings TypeGraph’s own base-relation system indexes up to the running library version: bootstrap DDL only runs on the very first boot, so an index shipped in a newer version reaches an already-initialized database through this step (built withCREATE INDEX CONCURRENTLYon PostgreSQL; a database whose indexes all exist settles from the catalog with no DDL). Contribution and system-index preparation have their own catalog checks and may still issue DDL, so the role it runs under must holdCREATE/ DDL privileges. Run it once at startup, outside request handlers and transactions. (store.evolve()likewise provisions any embedding field it introduces, so it too needs DDL privileges.) Deployments that never runcreateStoreWithSchema(manual-DDL boot with a plaincreateStoreattach) adopt new system indexes by callingstore.materializeSystemIndexes()once under a DDL-capable role after upgrading; deployments that must not run index builds inline at boot (large tables behind a readiness probe) passsystemIndexes: "skip"tocreateStoreWithSchemaand materialize out-of-band the same way. -
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 operation or an embedding write against a database that was never initialized — acreate({ embedding })or embedding update/delete — throwsStoreNotInitializedErrorrather than silently emittingCREATE TABLEon the hot path. (Vector reads are not marker-gated:store.search.vector,store.search.hybrid, and a query-builder.similarTo()predicate compile to SQL against the per-field table directly, so on an un-provisioned database they surface the engine’s own missing-relation error instead —no such table: tg_vec_…on SQLite,relation … does not existon Postgres. Same cause, same fix; usecreateVerifiedStoreto catch it at attach rather than at first query.) This is what lets a least-privilege role run vector ops: the table already exists. Graphs with nosearchable()orembedding()fields are unaffected. The Store is also raw and unversioned: its writes do not participate in the schema-version fence. Direct backend writes have the same semantics. Quiesce those writers yourself before changing schemas. -
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. ThrowsBaseSchemaMigrationErrorwhen deployment-wide base storage is missing, stale, or newer than the library,MigrationErroron graph-schema drift (safe or breaking),ConfigurationErrorwhen no graph 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. Its managed writes require a transactional backend with the schema-write fence; non-transactional and unsupported custom backends can attach for reads but fail closed on the first write.
The adapter equivalents (createAdapterStoreWithSchema and
createVerifiedAdapterStore) carry the same managed metadata. So does
createAdapterStore(..., { reconciled }) with a cached reconciliation snapshot,
and Stores returned by evolve() or rebound from an already-managed Store.
Check store.introspect().schemaVersion !== undefined at runtime. Calling
store.clear() deletes the schema rows and resets that Store to raw semantics;
reopen it through a managed factory before resuming version-fenced writes.
-
store.verifyContributions()diagnoses contribution storage;store.repairContributions()repairs safe findings under a privileged role. Every gate above trusts the marker row without probing the catalog, so a database whose strategy-owned tables were dropped out of band opens clean and fails at the first dependent read or write. This method compares each contribution currently expected by the active graph and backend strategies with its marker and the catalog. It does not audit retired marker rows, and a never-attempted contribution with neither marker nor table is omitted, so an empty result is not initialization proof. It is read-only (SELECTonly, no DDL) so the least-privilege role can run it, and it is deliberately not part of any open path. For a readiness check, construct the Store withcreateVerifiedStore()first and then run this diagnostic; otherwise use it as an operator check. The repair method re-audits current declarations, preserves data while repairingmissing-markerandfailed-materialization, and reportsstaleororphaned-markerasrequires-rebuild. Run repair through the DDL-capable migration role, not the least-privilege runtime role. Follow the per-state table in The store opens clean but a fulltext or vector read fails rather than applying one repair to every entry. -
store.probeContributions()is the read-only readiness check;store.rebuildContribution()is the destructive last resort. The two bracketrepairContributions()into one escalation ladder: probe (writes nothing) → repair (non-destructive) → rebuild (destructive, but scoped to the calling graph). The probe reports oneready/degradedentry per search projection and is safe on a read path, on a replica, and under the least-privilege role — it shares the detection logic of the other two rather than reimplementing it, so it cannot disagree with the gate the hot path actually consults. The rebuild is the only repair for astalecontribution, whose table exists at a shape the currentcreateDdlno longer produces; it deletes and refills only the calling graph’s rows in the shared fulltext table, escalating to drop → recreate when that table holds no other graph’s rows (under a database-scoped DDL advisory lock, since that DDL is database-global), and runs the whole sequence inside one transaction under the schema-write fence. It refuses withContributionRebuildUnsupportedErrorfor vector storage, whose embeddings exist only in the table it would drop (reason: "vector-source-unavailable"), and for astaleshape whose storage still holds other graphs’ rows (reason: "shared-storage-in-use", naming them indetails.otherGraphIds). Run rebuilds through the DDL-capable migration role, in a maintenance window: the transaction is held for the whole refill, and on PostgreSQL a drop’sACCESS EXCLUSIVElock blocks both searches and writes to any kind withsearchable()fields until it commits. Reach it from acreateStore()Store — the managed factory’s boot step refuses to open while a contribution isstale. See Contribution health: probe, repair, rebuild.
Contribution capability parity
Section titled “Contribution capability parity”backend.capabilities.contributions declares how far up the ladder a
backend goes. Each rung is separate because a backend can genuinely stop
at any of them, and a rung a backend cannot serve refuses with a typed
error rather than returning something that looks like success.
| Backend | supported |
probe |
rebuild |
|---|---|---|---|
| SQLite (better-sqlite3, bun:sqlite, libSQL, Durable Objects) | ✅ | ✅ | ✅ |
SQLite with transactionMode: "none" |
✅ | ✅ | ❌ no schema fence |
PostgreSQL (pg, postgres-js, PGlite, neon-serverless) |
✅ | ✅ | ✅ |
PostgreSQL over neon-http |
✅ | ✅ | ❌ no schema fence |
Custom fulltext strategy without dropDdl |
✅ | ✅ | ❌ no teardown DDL |
Fulltext disabled (fulltext: false) |
✅ | ✅ | ✅ with a schema fence |
rebuild requires two things at once: a fulltext strategy that declares
dropDdl on its contribution, and a transactional schema fence
(schemaWriteTransaction) to run the sequence under. The HTTP-only
PostgreSQL drivers cannot hold a session across statements, so they have
no fence — the same reason they already report
capabilities.execution.interactiveTransactions === false. A third-party strategy predating
dropDdl keeps working for every other operation and is reported as not
rebuildable rather than being dropped through a synthesized statement
TypeGraph guessed at. Vector contributions are never rebuildable on any
backend; that is a property of what TypeGraph stores, not of the engine. A
backend built with fulltext: false has no fulltext contribution at all, so
the first condition is vacuously satisfied and rebuild reduces to whether
the backend has the transactional schema fence — the same value it would
report if fulltext were still active on a driver with that fence.
fulltext: false stops creating and maintaining the fulltext table; it
never drops one. On a database that already carries fulltext rows,
disabling fulltext leaves them in place and unmaintained: a hard delete
performed while fulltext is off leaves an orphaned row behind in the
fulltext table, because hardDeleteNode’s cascade has no active strategy to
build a delete statement from. Re-enabling fulltext later therefore requires
the destructive contribution rebuild — store.rebuildContribution("fulltext"),
which drops and recreates the fulltext table — not
store.search.rebuildFulltext(): that method pages live nodes to recompute
their content, and a hard-deleted node has no row left in the node table for
it to page, so it never revisits, and therefore never clears, the orphan.
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/adapters/drizzle/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