Skip to content

Limitations

This page documents TypeGraph’s known limitations and constraints.

Some runtimes cannot hold a multi-statement database session and therefore cannot offer atomic transactions:

  • Cloudflare D1 — the D1 binding has no interactive transaction primitive (D1Database.batch(...) is transactional but batch-only).
  • drizzle-orm/neon-http — Neon’s HTTP driver issues each statement as an independent request; there is no session to bind a transaction to.

Cloudflare Durable Objects SQLite is not in this list: a store backed by drizzle(ctx.storage) is auto-detected as transactionMode: "do-sqlite", reports capabilities.execution.interactiveTransactions: true, and is fully atomic. An AdapterStore created from that backend also exposes the adapter-only store.withTransaction and tx.sql surfaces. See Backend Setup.

These backends report capabilities.execution.interactiveTransactions: false. Read-only store.batch(...) still runs, but each query may use an independent connection and observe a different database snapshot. (Whether the queries nonetheless reuse one connection is up to the adapter — the no-transaction path hands each query the same backend object.) Note this is a difference of degree, not of kind: on PostgreSQL, batch()’s implicit transaction runs at the default read-committed isolation, so queries there can also observe interleaved commits.

Write behavior depends on how the Store was constructed. A schema-managed Store fuses its schema fence into a write’s own statement when the write fuses, and fails closed for writes that need the transaction-scoped schema or constraint fence otherwise — see The guard every fused write shares below for which writes fuse and which refuse. A raw createStore() / createAdapterStore() without a reconciled snapshot still has no interactive transaction boundary. store.transaction(fn) refuses with a typed capability error rather than pretending to provide rollback; direct backend writes remain raw. Eligible operations that use a certified atomic SQL program can still be available on these roots, but that transport guarantee is separate from the interactive transaction capability.

These backends cannot honor the isolationLevel option on store.transaction(...); the method refuses before invoking its callback, so the collection-read snapshot recipe documented elsewhere does not apply here.

// On a raw D1 / neon-http Store, this refuses before the callback runs.
await store.transaction(async (tx) => {
await tx.nodes.Person.create({ name: "Alice" });
});

If you require atomicity or schema-version fencing, branch on the capability:

if (store.capabilities.execution.interactiveTransactions) {
await store.transaction(async (tx) => {
/* atomic */
});
} else {
// Use independent operations, or a supported certified atomic operation.
const person = await store.nodes.Person.create({ name: "Alice" });
const company = await store.nodes.Company.create({ name: "Acme" });
await store.edges.worksAt.create(person, company, { role: "Engineer" });
}

If you need atomic writes from an edge runtime, use drizzle-orm/neon-serverless (WebSocket-backed Pool) instead of drizzle-orm/neon-http.

TypeGraph distinguishes an interactive transaction, a static adapter batch, a certified atomic SQL program, and an authoritative one-statement command. store.transaction(...) is the interactive Store API: it pins a session and groups the callback’s operations. A static batch is adapter-internal (such as D1 batch() or a multi-row insert); it is not a public Store transaction and cannot make arbitrary Store calls atomic. A certified atomic SQL program is a closed ordered statement sequence whose transport preserves result slots and parameters and rolls back primary and sidecar writes when a later statement fails. An authoritative command is a single commands.execute write whose database statement returns the decision it made. It can provide a safe transactionless create/found path only when the backend has a durable arbiter.

Operational Identity, single-edge claim/cardinality enforcement, and any undeclared dynamic matchOn convergence that may write still require an interactive transaction and fail closed on a backend that cannot provide one. Outside the native durable-convergence envelope, an all-live ifExists: "return" endpoint batch is read-only and can return from its set-oriented root read without a transaction. Inside the native envelope, the authoritative upsert program runs before the Store knows every identity is live. It preserves the logical "found" result in one exchange, but may take incumbent-row locks and produce write amplification. Eligible direct edge batches on bundled roots are a separate exception: their closed native program carries the claim sidecars inside one atomic exchange. A declared edge matchIdentity persists a canonical endpoint/property key and has a unique database arbiter; eligible root getOrCreateByEndpoints calls can therefore use the authoritative one-statement command. The durable identity does not make unrelated Store operations, claims, or history/revision side effects transactionless.

Every static batch and every certified atomic program asserts the active schema version inside the very statement that writes, never as a preceding check — the fused create’s WHERE … is_active predicate, or the program’s leading schema_fence CTE. A stale version makes that statement match zero rows, so the write commits nothing, and the store re-reads and reports StaleVersionError instead of writing against a version that already moved on. This is what lets a "batch"-tier backend (capabilities.execution.unitOfWork === "batch" — Cloudflare D1’s batch(), Neon HTTP’s transaction(queries), which fix every statement before the first one runs and commit them together with no session in between) run schema-managed creates, updates and deletes, and bulk writes at all: the fence travels inside the one exchange it can hold, instead of needing a session to hold it separately. A singleton node update, upsertById, or delete fuses the same way as a create, through a one-entry certified atomic program, whenever its kind carries no declared unique constraint — except a node delete, which fuses even when the kind DOES carry one, because the atomic delete program releases that claim in the same statement. A singleton edge update or delete fuses the same way (EdgeCollection has no upsertById).

A write that needs more than that one guarded statement — because it must read a value it wrote earlier in the same write, hold an interactive callback open across round trips, maintain Operational Identity’s closure, hold history’s per-graph lock across a whole write cascade, or hold one transaction across a schema commit’s compare-and-swap — refuses on a "batch"-tier backend with BATCH_WRITE_UNSUPPORTED, naming which of those it needed:

reason What it needs
interactive-callback Hold an interactive callback transaction open across several round trips (store.transaction(fn)).
constraint-needs-probe Read a value it wrote earlier in the same write before deciding what to write next (a declared constraint’s probe-then-write).
identity Read and write Operational Identity’s closure across several round trips inside one held transaction.
history Hold the per-graph write lock and clock open across a whole write cascade (history: true / revisionTracking: true).
schema-commit Hold one transaction across its compare-and-swap read and its activating write (commitSchemaVersion / setActiveVersion).

A write that simply cannot fuse — an ineligible write kind, a singleton create/update/upsertById on a kind with a declared unique constraint, a tombstone-resurrection write a supplied id falls through to, or a derived backend — refuses with SCHEMA_WRITE_FENCE_UNSUPPORTED instead and carries no batchRefusal reason: that gate has no proven need to name, only its own plain limitation.

See BATCH_WRITE_UNSUPPORTED for where each reason surfaces in an error’s details.

For local @libsql/client connections (file: paths and file::memory:), createLibsqlBackend frames transactions with raw BEGIN IMMEDIATE/COMMIT statements on the client’s single stable connection. It deliberately avoids client.transaction(), which hands the client’s connection to the transaction and lazily opens a new one afterwards — for an in-memory database that new connection is a fresh, empty database (tursodatabase/libsql-client-ts#229). In-memory databases therefore work for all operations, including transactions. Remote Turso connections (libsql://, http(s)://) run each transaction on its own stream via the driver.

The trade-off of a single connection: a store-level operation awaited from inside a store.transaction callback (on the root store, rather than the tx context) can never run — the open transaction occupies the backend’s serialized execution slot until it completes — so the backend rejects it with a ConfigurationError instead of deadlocking.

// ✅ In-memory works, including transactions
const client = createClient({ url: "file::memory:" });
// ❌ Root-store access inside a transaction callback throws
await store.transaction(async (tx) => {
await store.nodes.Person.find(); // ConfigurationError — use tx.nodes
await tx.nodes.Person.find(); // ✅ transaction-scoped access
});

Variable-length traversals use two caps:

  1. Unbounded traversals (no maxHops option) are capped at 10 hops.
  2. Explicit maxHops values are validated up to 1000 hops (maxHops: >1000 throws).
  3. Cycle prevention is on by default. To skip cycle checks for speed, opt into cyclePolicy: "allow" (which may revisit nodes across hops).

This prevents runaway queries while still supporting deep, intentionally bounded traversals.

// Implicitly limited to 10 hops
store
.query()
.from("Person", "p")
.traverse("reportsTo", "e")
.recursive()
.to("Person", "manager");
// Explicit limits up to 1000 are honored
store
.query()
.from("Person", "p")
.traverse("reportsTo", "e")
.recursive({ maxHops: 200 }) // honored
.to("Person", "manager");
// Explicit limits above 1000 throw
store
.query()
.from("Person", "p")
.traverse("reportsTo", "e")
.recursive({ maxHops: 2000 }) // throws
.to("Person", "manager");

The unbounded-traversal limit is defined as MAX_RECURSIVE_DEPTH:

import { MAX_RECURSIVE_DEPTH } from "@nicia-ai/typegraph";
// MAX_RECURSIVE_DEPTH = 10

Managed Store factories own their local SQLite or PGlite connection, and their store.close() method releases it. The local backend factories createLocalSqliteBackend and createLocalPgliteBackend likewise expose an owned backend whose close() releases its resources.

Bring-your-own adapter factories leave connection ownership with you. For createSqliteBackend, createPostgresBackend, and createLibsqlBackend, you are responsible for:

  1. Creating and configuring the database connection
  2. Implementing connection pooling for production use
  3. Closing connections when done
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { createSqliteBackend, generateSqliteMigrationSQL } from "@nicia-ai/typegraph/adapters/drizzle/sqlite";
// You manage the connection
const sqlite = new Database("app.db");
sqlite.exec(generateSqliteMigrationSQL());
const db = drizzle(sqlite);
const backend = createSqliteBackend(db);
const store = createStore(graph, backend);
// You close the connection
sqlite.close();

For production deployments, use connection pooling:

import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { createPostgresBackend } from "@nicia-ai/typegraph/adapters/drizzle/postgres";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Maximum connections
});
const db = drizzle(pool);
const backend = createPostgresBackend(db);

In the bring-your-own example above, store.close() leaves the supplied driver open. Close that driver or pool through its own API.

Where predicates in unique constraints cannot be serialized. If you use schema serialization for versioning or migration, predicates are stored as "[predicate]" and cannot be reconstructed.

// This predicate works at runtime...
unique({
name: "email_unique_when_active",
fields: ["email"],
where: (props) => props.status.isNotNull(),
});
// ...but serializes as:
// { "where": "[predicate]" }

Workaround: For full schema serialization support, avoid predicates in unique constraints. Use application-level validation instead.

Vector and hybrid search work across all primary backends via a pluggable VectorStrategy. Each backend advertises its capabilities through backend.capabilities.vector ({ supported, metrics, indexTypes, maxDimensions }):

Backend Requirement Metrics
PostgreSQL pgvector extension (HNSW / IVFFlat) cosine, l2, inner_product
SQLite sqlite-vec extension (vec0 KNN) cosine, l2
libSQL / Turso built-in native engine (DiskANN); nothing to load cosine, l2
D1 Not supported

Note that inner_product is PostgreSQL-only — sqlite-vec and libSQL support cosine and l2 only.

Using vector predicates on unsupported backends throws UnsupportedPredicateError:

try {
await store
.query()
.from("Document", "d")
.whereNode("d", (d) => d.embedding.similarTo(queryVector, 10))
.execute();
} catch (error) {
if (error instanceof UnsupportedPredicateError) {
// Vector search not available on this backend
}
}

Complex query chains may occasionally require explicit type annotations when TypeScript cannot infer the full type. This is rare but can occur with deeply nested selects or unions.

// If type inference fails, add explicit type
const results = await store
.query()
.from("Person", "p")
.select((ctx) => ({
name: ctx.p.name as string, // Explicit annotation
}))
.execute();

Bulk operations (bulkCreate, bulkInsert, bulkUpsertById, bulkDelete) have practical limits based on your database:

Database Recommended Batch Size
SQLite 500-1000 items
PostgreSQL 1000-5000 items

For larger datasets, batch your operations:

const BATCH_SIZE = 1000;
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const batch = items.slice(i, i + BATCH_SIZE);
await store.nodes.Person.bulkCreate(batch);
}

Bundled PostgreSQL roots using a recognized session-capable driver, Neon HTTP, Cloudflare D1, and libSQL roots can use one schema-fenced native atomic program for schema-managed nodes.bulkInsert() and nodes.bulkCreate() calls when the node has no Operational Identity, history, or revision work. The program can compose fulltext/vector projections with the complete supported uniqueness and disjointness claim set for every member. Session-capable PostgreSQL executes that program on one pinned transaction; Neon HTTP, D1, and libSQL submit one transport batch. Advertised same-kind or hierarchy-wide uniqueness claims, disjointness claims, and mixed families are acquired in canonical order; compatibility reads preserve rows written under legacy claim axes. Claim-free members may participate alongside claimed members. IDs may be generated, caller-supplied, or mixed, and bulkCreate() returns rows in input order. This is an internal optimization, not a general Store batch API. Identity-enabled nodes, history/revision tracking, a member beyond the executor’s declared claim-input budget, missing schema-fence support, and other unsupported shapes fail closed to the existing transaction or fallback behavior.

The transport inventory for the supported libSQL root records one client batch submission and zero client execute calls for both generated-ID claim-free batches, multiple-claim batches, cross-scope claims, and claim-plus-projection batches. This is a measured submission count, not a wall-clock RTT benchmark; fallback paths are intentionally not assigned a latency claim. On D1, claim work is chunked inside the same submission rather than imposing a batch-wide ceiling. Each member has 87 claim-input binds after its row and fence: a canonical claim costs six, each legacy hierarchy-wide uniqueness probe costs nine, and each legacy disjointness probe costs six. Custom executors should call the exported atomicNodeClaimInputCost() owner rather than reproduce this formula. A member beyond that bound retains the portable behavior.

Direct edges.bulkInsert() and edges.bulkCreate() calls on those same roots use one schema-fenced native program when history and revision capture are disabled. Declared durable match identities and one, unique, or oneActive cardinality are maintained inside that exchange; any endpoint, identity, or cardinality refusal rolls the whole call back. Transaction-scoped stores, derived backends, custom backends without the corresponding exact-root semantic registration, and dynamic get-or-create convergence retain the interactive path.

Direct edge bulkDelete() calls use the same exact-root exchange and refuse a foreign-kind ID atomically. Restricted node bulkDelete() also releases every unique or disjoint claim owned by rows it tombstones in the same program, while enforcing live connected edges in SQL. Identity, projection, history, revision, cascade, and disconnect shapes retain their transaction path. bulkUpsertById() remains a resolved mutation set because it must read and schema-validate a database preimage before its writes are known. Bundled serverless roots can submit an eligible distinct-ID, live-row resolved set as one native exchange after that read. Bundled session-capable PostgreSQL can bind the same program to the exact collection-opened, caller-supplied, or adopted transaction; this is a bounded statement sequence on the pinned session, not one network exchange. Update-only sets use a guarded update; sets containing both fresh creates and updates include a terminal database assertion that rolls the whole exchange back when any guarded postimage is absent. Repeated IDs, resurrections, temporal changes, claims, edge sidecars (including durable edge match identity), history/revision capture, ordinary derived backends, and unregistered sessions use the interactive path. On D1’s 100-parameter budget, each native statement carries at most 17 node mutations or 6 edge mutations. Larger eligible sets are chunked inside the same atomic transport submission; each chunk has its own terminal postimage assertion, so one refusal rolls every sibling chunk back rather than weakening the set contract. A D1 submission is bounded to 512 node members or 187 edge members; larger sets fail closed to the portable path instead of building an unbounded request. Other backends derive their statement width from their declared bind budget and retain an absolute 512-member submission ceiling. The operation returns an explicit unsupported verdict before issuing program SQL; the Store never infers fallback safety from a missing result. Once a session program starts, a savepoint preserves the surrounding transaction for typed refusal diagnosis.

Node bulkReplaceById() avoids that structural preimage read by accepting only complete replacement documents and distinct IDs. On an eligible bundled root, the complete call—including claim ownership changes and fulltext/vector sidecars—uses one atomic transport submission. Live rows retain their stored validity windows; tombstones receive a freshly stamped window. Operational Identity and history/revision capture use the portable path. Custom backends must register and semantically certify the independent replaceNodes family; transport registration or another node family is not evidence for replacement.

Eligible singleton update() and delete() calls reuse those same registered families. Plain or projected node updates, unconstrained non-durable-identity edge updates, all direct edge deletes, and plain restricted node deletes remain two-exchange operations—one authoritative read/gate and one atomic mutation—because TypeGraph must validate merged update properties and must preserve the rule that a missing delete fires no operation hooks. This removes explicit transaction transport from the eligible shape; it does not turn claims, edge sidecars, temporal, captured, derived-backend, or caller-transaction writes into autocommit operations.

That singleton update path uses optimistic convergence: the mutation asserts the row preimage it read and retries a moved preimage up to four times. Under sustained same-row contention it can throw DatabaseOperationError where an interactive transaction would have waited to serialize the writers. This applies to eligible update() calls and the live-row leg of upsertById() on registered exact-root atomic transports. Caller transactions and other ineligible shapes continue to use the serialized transaction path. Applications using an atomic root should retry the operation when sustained contention can move the row throughout all four attempts.

One bulkUpsertById batch cannot hand a constrained value between rows

Section titled “One bulkUpsertById batch cannot hand a constrained value between rows”

bulkUpsertById applies items in order for the purpose of deciding each row’s final props, but it groups the writes: every create in the batch runs before every update. A batch where one item releases a constrained value and a later item claims it therefore fails, where the same operations applied one at a time succeed.

  • Nodes: releasing and re-claiming a unique constraint value in one batch throws UniquenessError — the claiming create is checked while the releasing row still reserves the value.
  • Edges: ending the lone oneActive edge from a source while creating its replacement throws CardinalityError, for the same reason.

Bulk semantics are set-like, not scripted — a batch states the rows you want, not an order to reach them in — so this is a stated limitation rather than a pending fix. It always surfaces as a typed error, never as a dropped write. Split the handoff across two batches (release, then claim), or apply the conflicting items one at a time — as sequential upsertById calls for nodes, and as update then create for edges, which have no single-item upsert. See Data Sync for the worked example.

TypeGraph ships focused algorithms on store.algorithms.* — shortest path (weighted and unweighted), reachability, k-hop neighborhoods, degree, exact weakly connected components, deterministic label propagation, and global/personalized PageRank. See Graph Algorithms for the full API.

The following heavier analytics are not provided:

  • Modularity-optimizing community detection such as Leiden or Louvain
  • Centrality measures beyond degree (betweenness, closeness, eigenvector)
  • Strongly connected components
  • Topological sort
  • Graph partitioning

For these use cases, export your data via .query().traverse() or store.subgraph() and use a specialized library such as graphology in memory, or move to a dedicated graph database.

TypeGraph is designed for single-database deployments. It does not support:

  • Distributed storage across multiple databases
  • Sharding
  • Cross-database queries
  • Replication coordination

For distributed graph workloads, consider a dedicated graph database.

Temporal queries (asOf, includeEnded) work correctly but have some constraints:

  • Point-in-time queries cannot be combined with streaming (.stream())
  • validFrom defaults to the record’s own creation timestamp when omitted, so asOf queries work out of the box; an end boundary still requires an explicit validTo — an open validTo means “still valid”. A record written with a validTo at or before its own creation instant is “born already ended” and stores no lower bound instead, so it reads back at every asOf before that end
  • Rows an older library version stored with a backwards window (valid_from > valid_to) are readable at no coordinate, and upgrading does not rewrite them. Making them observable is an explicit operator action: run repairInvertedValidityWindows({ relations: "live-and-recorded", mode: "apply" }) while writers are stopped, then re-baseline any outstanding merge branches. See Repairing inverted validity windows
  • Clock skew between application servers can affect temporal accuracy

Recorded-time capture (createStore(graph, backend, { history: true })) and store.asOfRecorded(T) add a second temporal axis with these constraints. Use createAdapterStore(..., { history: true }) instead when the application must adopt a caller-owned transaction:

  • Opt-in, no backfill. Capture only sees changes committed after it is enabled; an entity that already exists is first recorded the next time it is written. Enable it on a fresh graph for complete history.
  • TypeGraph-write capture. Built-in capture records TypeGraph collection writes only. Out-of-band database writes and row-returning raw SQL paths are not captured into the recorded relations.
  • Reconstructing reads only. A recorded view exposes point reads (getById / getByIds), bounded deterministic scan() pages, query(), subgraph(), and the graph algorithms. Broad filtered collection reads (find / count / findFrom), search, and fulltext / vector predicates are refused — those indexes reflect current state and cannot answer a recorded-time query.
  • Transactional backend required. Capture needs a backend with atomic transactions and statement execution — the built-in SQLite / PostgreSQL backends qualify. A custom backend must implement executeStatement (optional on the GraphBackend interface, but required once history: true is set) or enabling capture throws a ConfigurationError at write time. On an AdapterHistoryStore, raw tx.sql is disabled under history: true; adopt external transactions with store.withRecordedTransaction(...) instead of store.withTransaction(...) (which is a compile error on a history store).
  • Reconstruction cost. Recorded reads rebuild from the history relations and are slower than live reads, most noticeably for full-graph subgraph / algorithm reconstructions on PostgreSQL.
  • PostgreSQL capture requires READ COMMITTED. Every captured commit advances a single recorded-clock row for the graph. TypeGraph refuses PostgreSQL REPEATABLE READ / SERIALIZABLE history-capture transactions because snapshot isolation cannot safely allocate that per-graph recorded clock inside the captured transaction. Omit the transaction isolation option, or set it to read_committed.
  • Recorded anchors are per graph. Each captured transaction advances a fixed-width logical revision and pairs it with a non-decreasing physical wall-time high-water mark. TypeGraph does not provide a cross-graph recorded anchor. See Logical revision and physical time.
  • The preview schema needs an offline migration. Timestamp-only anchors and PostgreSQL recorded relations using timestamptz predate numeric recorded revisions and the r1:<revision>:<timestamp> API encoding. Run migrateLegacyRecordedTime() while writers are stopped, then use migrateRecordedAnchor() for checkpoints held outside TypeGraph. See Migrating preview recorded time.

Automatic migrations (createStoreWithSchema) only handle additive changes:

Change Type Auto-Migrated
Add new node type Yes
Add new edge type Yes
Add optional property Yes
Add required property No
Remove property No
Rename type No
Change property type No

Breaking changes throw MigrationError and require manual migration.