Skip to content

Errors

TypeGraph uses typed errors to communicate specific failure conditions. All errors extend the base TypeGraphError class and include categorization, contextual details, and actionable suggestions.

Every error is categorized to help determine the appropriate response:

Category Description Typical Response
user Invalid input or misuse of API Fix the input and retry
constraint Graph constraint violated Handle as business logic violation
system Internal or infrastructure error Log, alert, potentially retry
import { isUserRecoverable, isConstraintError, isSystemError } from "@nicia-ai/typegraph";
try {
await store.nodes.Person.create(data);
} catch (error) {
if (isUserRecoverable(error)) {
// Show validation errors to user
return { error: error.toUserMessage() };
}
if (isConstraintError(error)) {
// Handle business rule violation
return { error: "This operation violates a constraint" };
}
if (isSystemError(error)) {
// Log and alert
console.error(error.toLogString());
throw error;
}
}

Base error class for all TypeGraph errors.

class TypeGraphError extends Error {
readonly code: string;
readonly category: ErrorCategory;
readonly details: Readonly<Record<string, unknown>>;
readonly suggestion?: string;
// Format error for end users (includes suggestion if available)
toUserMessage(): string;
// Format error for logging (includes code, category, and details)
toLogString(): string;
}
type ErrorCategory = "user" | "constraint" | "system";

Properties:

Property Type Description
code string Machine-readable error code
category ErrorCategory Error classification for handling
details Record<string, unknown> Additional context about the error
suggestion string | undefined Actionable guidance for resolution

Methods:

Method Returns Description
toUserMessage() string Human-readable message with suggestion
toLogString() string Detailed string for logging/debugging

Thrown when schema validation fails during node or edge creation/update. Includes structured issue details with context about which entity failed.

interface ValidationErrorDetails {
readonly issues: readonly ValidationIssue[];
readonly entityType?: "node" | "edge";
readonly kind?: string;
readonly operation?: "create" | "update";
readonly id?: string;
}
interface ValidationIssue {
readonly path: string;
readonly message: string;
readonly code?: string;
}

Example:

try {
await store.nodes.Person.create({ name: "" }); // Empty name fails min(1)
} catch (error) {
if (error instanceof ValidationError) {
console.log(error.category); // "user"
console.log(error.details.kind); // "Person"
console.log(error.details.operation); // "create"
console.log(error.details.issues);
// [{ path: "name", message: "String must contain at least 1 character(s)" }]
console.log(error.toUserMessage());
// "Validation failed for Person create: name - String must contain at least 1 character(s)
//
// Suggestion: Check the data you're providing matches the schema..."
}
}

A ValidationError whose issue carries the exported code INVERTED_VALIDITY_WINDOW refused a valid-time window of negative width: the write’s validTo precedes the row’s effective validFrom, so the row would have stopped being true before it started and no asOf coordinate could observe it. Branch on the code rather than on the message.

import { INVERTED_VALIDITY_WINDOW_CODE, ValidationError } from "@nicia-ai/typegraph";
try {
// The stored validFrom is later than this end.
await store.edges.worksAt.update(edgeId, {}, { validTo: "2020-01-01T00:00:00.000Z" });
} catch (error) {
if (
error instanceof ValidationError &&
error.details.issues.some((issue) => issue.code === INVERTED_VALIDITY_WINDOW_CODE)
) {
// Supply an explicit validFrom for a historical window, or drop validTo.
}
}

Interchange import records the same refusal as a per-row error prefixed with the code, so one bad row does not abort the import; trusted import refuses the whole stream with TrustedImportError reason invalid_stream. A zero-width window (validTo === validFrom) is legal and never raises this, and neither is a write that STAMPS its own start while carrying only a historical validTo: any create, and a node resurrection through upsertById / bulkUpsertById. Both store no lower bound instead. An edge resurrection RETAINS the bound the row already holds, so a validTo before that bound still raises this.

A ValidationError whose issue carries the exported code IMMUTABLE_VALIDITY_LOWER_BOUND refused a validFrom the write could not apply. A live row’s lower bound is history: an in-place update never rewrites valid_from, so a bound naming a different instant is refused rather than accepted and silently dropped. The message names both instants — the one stated and the one the row stores — so you can restate the stored bound without a second read.

import { IMMUTABLE_VALIDITY_LOWER_BOUND_CODE, ValidationError } from "@nicia-ai/typegraph";
try {
// The row is live and started at some other instant.
await store.nodes.Person.upsertById(id, props, { validFrom: "2020-01-01T00:00:00.000Z" });
} catch (error) {
if (
error instanceof ValidationError &&
error.details.issues.some(
(issue) => issue.code === IMMUTABLE_VALIDITY_LOWER_BOUND_CODE,
)
) {
// Omit validFrom, or restate the bound the row already holds.
}
}

What deliberately does not raise it:

  • Restating the stored bound. Naming the instant the row already holds is accepted; there is nothing to apply and nothing being ignored.
  • A create, or a resurrection. Both write a fresh window, so a stated validFrom is stored — that is the way to give a row a different lower bound.
  • getOrCreateByEndpoints returning an existing edge. That branch performs no write, so validFrom / validTo describe the row to create if none is found. clearValidTo is refused on a live return-mode match because it names a mutation; use ifExists: "update".
  • A node upsert or endpoint-matched edge update with onImmutableLowerBound: "preserve". This explicitly treats validFrom as create/resurrection-only input. A live-row update keeps its stored lower bound while still applying props and validTo; the default remains "refuse" so an unqualified bound is never silently dropped. Edge updates use the policy with ifExists: "update"; the bulk edge form sets it per item.

Under the default "refuse" policy, it reaches every path that accepts validFrom against a live row: upsertById, bulkUpsertById (including a repeated id in one batch, judged against the row the batch just queued), getOrCreateByEndpoints / bulkGetOrCreateByEndpoints with ifExists: "update", and interchange import’s onConflict: "update" legs — where, as with the inverted-window refusal, it is recorded as a per-row error prefixed with the code rather than aborting the import.

A ValidationError whose issue carries the exported code ENTITY_ALREADY_EXISTS refused a create because the id is already taken. details.entityType says whether a node or an edge was refused and details.kind names its kind.

import { ENTITY_ALREADY_EXISTS_CODE, ValidationError } from "@nicia-ai/typegraph";
try {
await store.nodes.Person.create({ name: "Alice" }, { id: takenId });
} catch (error) {
if (
error instanceof ValidationError &&
error.details.issues.some((issue) => issue.code === ENTITY_ALREADY_EXISTS_CODE)
) {
// Use a different id, or update the existing entity.
}
}

The code is the same whichever layer noticed, on either backend. A node create finds out from its own existence probe — but the probe and the INSERT are two statements, and PostgreSQL does not serialize two write transactions under its default READ COMMITTED isolation, so a concurrent create of the same NEW id can commit in between and the engine refuses the INSERT instead. (SQLite’s BEGIN IMMEDIATE gives the writer slot to one transaction at a time, so its probe always sees the winner’s row.) An edge create has no existence probe at all, so the engine’s refusal is always what reports a taken edge id. All of these raise the same error, so a caller retrying a generated id needs one branch, not several.

details.id names the taken id, and is present for every single-entity create. It is absent only when the refused statement inserted more than one row: the engine reports that the statement collided without saying which row did, and its transaction is already aborted, so there is nothing left to probe. No race is needed to reach that — a bulk create of edges, whose ids you supplied and which nothing probes, is refused this way on every backend. Treat details.id as optional if you create in bulk.

This is about identity, not values. A conflict on a declared unique constraint raises UniquenessError instead, and a violated unique: true index declaration surfaces as the engine’s own failure — neither is reshaped into this error.

Thrown when attempting to create a node that violates a disjointness constraint.

// If Person and Organization are disjoint:
await store.nodes.Person.create({ name: "Alice" }, { id: "entity-1" });
try {
// Same ID, different disjoint type
await store.nodes.Organization.create({ name: "Acme" }, { id: "entity-1" });
} catch (error) {
if (error instanceof DisjointError) {
console.log(error.category); // "constraint"
console.log(error.details);
// { nodeId: "entity-1", attemptedKind: "Organization", conflictingKind: "Person" }
console.log(error.suggestion);
// "Use a different ID for the new node, or delete the existing node first..."
}
}

Thrown when an identity mutation would make the assertion ledger contradictory — for example asserting two nodes are the same after they were asserted different, folding a same-class pair the ontology forbids, or importing an archive whose assertions conflict with the target graph. Only raised on identity-enabled graphs.

try {
await tx.identity.assertSame(alice, aliceCopy);
} catch (error) {
if (error instanceof IdentityContradictionError) {
console.log(error.code); // "IDENTITY_CONTRADICTION"
console.log(error.category); // "constraint"
console.log(error.details);
// {
// operation: "assertSame", // "assertSame" | "assertDifferent" | "fold" | "import"
// a: { kind: "Person", id: "..." },
// b: { kind: "Person", id: "..." },
// reason: "different-assertion", // "different-assertion" | "same-class" | "disjoint-kinds"
// conflictingAssertionId: "...", // present when an existing assertion conflicts
// conflictingKinds: ["Person", "Organization"], // present when reason is "disjoint-kinds"
// }
console.log(error.suggestion);
// "Retract the conflicting identity assertion or correct the graph ontology before retrying."
}
}

IdentityValidityWindowError refuses a future start, future end, inverted window, or a second non-identical open window for one current semantic pair. Its code identifies the reason: IDENTITY_VALIDITY_FUTURE_START, IDENTITY_VALIDITY_FUTURE_END, IDENTITY_VALIDITY_INVERTED, or IDENTITY_VALIDITY_OPEN_WINDOW_CONFLICT.

IdentityEndpointValidityError (IDENTITY_ENDPOINT_VALIDITY) means an explicit assertion window extends outside an endpoint node’s own validity or deletion bounds. Future or inverted identity windows are user-category input errors. A second non-identical open window and an endpoint-window conflict are constraint-category errors. Both classes are package-root exports.

Detected at merge plan time when the branches being merged carry opposing or otherwise contradictory identity truth: one branch asserts a pair same while another asserts it different (directly, or transitively through a chain of same assertions no single branch ever wrote), a branch retracts an assertion that a different branch reasserts under a new id (a retract/reassert race — a branch that reasserts a pair it also retracted itself is convergent, not a conflict, and merges cleanly), or a branch asserts an identity relation over a node another branch deleted. Extends MergeError, so an instanceof MergeError catch covers it alongside the other merge failures.

merge() and IdentityMergeConflictError are both exported from @nicia-ai/typegraph/graph-merge, not the package root. merge() takes an array of branches and never throws a MergeError — it returns a Result<MergeReport, MergeError>:

import { merge, IdentityMergeConflictError, isErr } from "@nicia-ai/typegraph/graph-merge";
const result = await merge(store, [branch]);
if (isErr(result)) {
if (result.error instanceof IdentityMergeConflictError) {
console.log(result.error.code); // "GRAPH_MERGE_IDENTITY_CONFLICT"
console.log(result.error.details);
}
throw result.error;
}

Returned when merge(), mergeIncremental(), or applyMergePlan() resolves a plan whose final graph violates a deterministic store constraint. The store remains the owner of constraint enforcement: the merge translates its typed refusal only at the commit boundary, after the transaction has rolled back.

import {
isErr,
merge,
MergeConstraintConflictError,
} from "@nicia-ai/typegraph/graph-merge";
const result = await merge(store, branches);
if (isErr(result) && result.error instanceof MergeConstraintConflictError) {
console.log(result.error.code); // "GRAPH_MERGE_CONSTRAINT_CONFLICT"
console.log(result.error.category); // "constraint"
console.log(result.error.details.constraintCode); // e.g. "CARDINALITY_ERROR"
console.log(result.error.details.edgeKind); // copied from the store error
console.log(result.error.cause); // the original CardinalityError, etc.
}

Cardinality, uniqueness, endpoint, disjointness, and restricted-delete refusals share this surface when they arise from node or edge application. The planner normally co-buckets nodes with the same declared unique key, but a late store-owned uniqueness refusal uses the same completeness boundary rather than falling back to a system error. Identity truth conflicts retain IdentityMergeConflictError; backend, environment, and stale-plan failures retain their existing system errors. Constraint failure is atomic: neither graph writes nor merge provenance records survive.

The reviewable merge lifecycle also returns errors in its Result arm. It does not throw them:

import {
applyMergePlan,
isErr,
planMerge,
StaleMergePlanError,
} from "@nicia-ai/typegraph/graph-merge";
const planned = await planMerge(store, branches, options);
if (isErr(planned)) throw planned.error;
const applied = await applyMergePlan(store, planned.data);
if (isErr(applied)) {
if (applied.error instanceof StaleMergePlanError) {
// The reviewed artifact no longer describes the target. Plan and review again.
}
throw applied.error;
}
Error Code Meaning
MergePlanCapabilityError GRAPH_MERGE_PLAN_CAPABILITY The target cannot supply a durable revision fence for a cross-time plan. Enable revisionTracking or history; the contiguous merge() wrappers retain their documented compatibility behavior.
MergePlanningStaleError GRAPH_MERGE_PLANNING_STALE The target revision changed between the planner’s opening and closing observations. No artifact is returned.
StaleMergePlanError GRAPH_MERGE_PLAN_STALE The target moved after planning, the plan already succeeded, or another concurrent application won. No plan writes committed.
InvalidMergePlanError GRAPH_MERGE_PLAN_INVALID The value failed the versioned plan schema or a semantic invariant.
UnsupportedMergePlanVersionError GRAPH_MERGE_PLAN_VERSION_UNSUPPORTED formatVersion is not supported by this TypeGraph version.
MergePlanDigestMismatchError GRAPH_MERGE_PLAN_DIGEST_MISMATCH Canonical plan content differs from the recorded digest.
MergePlanTargetMismatchError GRAPH_MERGE_PLAN_TARGET_MISMATCH The plan names a different graph id from the supplied target.
MergePlanSchemaMismatchError GRAPH_MERGE_PLAN_SCHEMA_MISMATCH The plan was resolved under a different active schema version or hash.
MergePlanOriginMismatchError GRAPH_MERGE_PLAN_ORIGIN_MISMATCH The target has an independently-created revision clock, even if its numeric revision happens to match.
CandidateSourceError GRAPH_MERGE_CANDIDATE_SOURCE A built-in candidate source failed. details identifies its source id, entity kind, and operation context.
MatchEvidenceError GRAPH_MERGE_EVIDENCE Candidate evidence is malformed or a score is non-finite. NaN and infinity are refused, never serialized or silently dropped.

Plan validation and the target/schema/origin/revision fence run before canonical writes. The revision check is inside the same transaction as apply, so two concurrent attempts cannot both commit. A stale plan is not repaired or adapted: create a new plan and obtain approval for its new digest.

Plans may contain the complete proposed application data. Their digest detects content changes and gives approval systems a stable identity, but it is not a signature and does not authenticate storage, authorize a caller, or prove who created the artifact. Protect plan data and enforce those trust decisions in the application before calling applyMergePlan().

Thrown when an edge is created with invalid endpoint types.

// If worksAt only allows Person -> Company:
try {
await store.edges.worksAt.create(company, person, {}); // Wrong direction
} catch (error) {
if (error instanceof EndpointError) {
console.log(error.category); // "constraint"
console.log(error.suggestion);
// "Check the edge definition to see which node types are allowed..."
}
}

Thrown when a source-dependent edge receives a source/target combination that matches no declared pair. It extends TypeGraphError directly, so catching EndpointError alone does not catch it. An invalid source kind continues to produce EndpointError.

import { EndpointPairError } from "@nicia-ai/typegraph";
try {
// Dynamic callers are checked at runtime, too.
// assignedTo allows Employee -> Department and Student -> Course.
await store.getEdgeCollection("assignedTo").create(employee, course, {});
} catch (error) {
if (error instanceof EndpointPairError) {
console.log(error.code); // "ENDPOINT_PAIR_ERROR"
console.log(error.category); // "constraint"
console.log(error.details);
// {
// edgeKind: "assignedTo", endpoint: "pair",
// fromKind: "Employee", toKind: "Course",
// allowedPairs: [
// { from: "Employee", to: "Department" },
// { from: "Student", to: "Course" },
// ],
// }
}
}

Malformed target maps and graph registrations that widen built-in constraints fail at configuration time with ConfigurationError.

Thrown when a cardinality constraint is violated.

// If worksAt has cardinality: "one" (person can only work at one company):
await store.edges.worksAt.create(alice, acme, { role: "Engineer" });
try {
await store.edges.worksAt.create(alice, otherCompany, { role: "Consultant" });
} catch (error) {
if (error instanceof CardinalityError) {
console.log(error.category); // "constraint"
console.log(error.details);
// { edgeKind: "worksAt", fromKind: "Person", fromId: "<alice-id>", cardinality: "one", existingCount: 1 }
console.log(error.suggestion);
// "Remove the existing edge before creating a new one, or update the existing edge..."
}
}

Thrown when a uniqueness constraint is violated.

// If email has a unique constraint:
await store.nodes.Person.create({ name: "Alice", email: "alice@example.com" });
try {
await store.nodes.Person.create({ name: "Bob", email: "alice@example.com" });
} catch (error) {
if (error instanceof UniquenessError) {
console.log(error.category); // "constraint"
console.log(error.details);
// { constraintName: "unique_email", kind: "Person", existingId: "<alice-id>", newId: "<bob-id>", fields: ["email"] }
console.log(error.suggestion);
// "Use a different value for the unique field, or update the existing record..."
}
}

Thrown when a direct edge create collides with the edge kind’s declared matchIdentity. Use getOrCreateByEndpoints() when the intended behavior is to return the existing identity owner.

Thrown when a referenced node does not exist.

try {
await store.nodes.Person.update("nonexistent-id", { name: "New Name" });
} catch (error) {
if (error instanceof NodeNotFoundError) {
console.log(error.category); // "user"
console.log(error.details); // { kind: "Person", id: "nonexistent-id" }
console.log(error.suggestion);
// "Verify the node ID is correct and the node hasn't been deleted..."
}
}

Thrown when a referenced edge does not exist.

try {
await store.edges.worksAt.update("nonexistent-edge", { role: "Manager" });
} catch (error) {
if (error instanceof EdgeNotFoundError) {
console.log(error.category); // "user"
console.log(error.details); // { kind: "worksAt", id: "nonexistent-edge" }
console.log(error.suggestion);
// "Verify the edge ID is correct and the edge hasn't been deleted..."
}
}

Thrown when referencing a node or edge type that doesn’t exist in the graph definition.

try {
await store.query().from("NonExistentType", "n").execute();
} catch (error) {
if (error instanceof KindNotFoundError) {
console.log(error.category); // "user"
console.log(error.details); // { kindName: "NonExistentType", entity: "node" }
console.log(error.suggestion);
// "Check the graph definition to see which node and edge types are available..."
}
}

Thrown when an edge references a node that doesn’t exist.

try {
await store.edges.worksAt.create(
{ kind: "Person", id: "nonexistent" },
company,
{ role: "Engineer" }
);
} catch (error) {
if (error instanceof EndpointNotFoundError) {
console.log(error.category); // "user"
console.log(error.details);
// { edgeKind: "worksAt", endpoint: "from", nodeKind: "Person", nodeId: "nonexistent" }
console.log(error.suggestion);
// "Create the referenced node first, or verify the node ID is correct..."
}
}

Thrown when delete is blocked due to existing edges (when onDelete: "restrict").

// If Person has edges and onDelete is "restrict":
try {
await store.nodes.Person.delete(alice.id);
} catch (error) {
if (error instanceof RestrictedDeleteError) {
console.log(error.category); // "constraint"
console.log(error.details);
// { nodeKind: "Person", nodeId: "<alice-id>", edgeCount: 3, edgeKinds: ["worksAt", "authored"] }
console.log(error.suggestion);
// "Delete all edges connected to this node first, or change the delete behavior..."
}
}

Thrown when the store, backend, or schema definition is misconfigured.

// Using transactions on D1 (which doesn't support them):
try {
await store.transaction(async (tx) => {
// ...
});
} catch (error) {
if (error instanceof ConfigurationError) {
console.log(error.category); // "system"
console.log(error.suggestion);
// "Check the backend documentation for supported features..."
}
}

Definition-time unique-constraint refusals

Section titled “Definition-time unique-constraint refusals”

defineGraph() validates every node kind’s unique constraints when the graph is defined, rather than leaving a broken where clause to surface as odd behavior on the first write. Three states are refused with ConfigurationError:

  • A where callback that does not return a predicatedetails carries kind and constraintName.
  • A predicate naming a field the kind’s schema does not declaredetails adds field and declaredFields.
  • A where clause on a kind whose schema is not an object schema (it exposes no .shape, so there is no declared-field set to check the clause against) — details carries kind and constraintName. Refused rather than left unvalidated, because skipping the check silently would disable this guard for exactly the untyped callers it exists for. A plain unique: [{ fields }] on such a schema is not refused: it names props by key and evaluates fine against a non-object schema.

All three carry only the class-level code CONFIGURATION_ERROR; match them by class, not by a details.code. The equivalent invariant on the graph-extension document path does have a stable code, UNKNOWN_UNIQUE_WHERE_FIELD.

A constraint built outside defineGraph never passed this gate, so the non-predicate case is refused at evaluation too: checkWherePredicate throws the same ConfigurationError (with constraintName and fields) on the write path instead of treating a broken clause as one that applies to every row. All three readers of a where clause — definition-time validation, per-write evaluation, and persistence-time capture — now agree, because they read it through one shared function.

Because the check evaluates the clause, a where callback now runs once at definition time in addition to its per-write evaluations — keep it pure. The check applies to node kinds whose schema exposes an object shape; edge unique constraints are not validated here. Statically typed callers were already unable to name an undeclared field, so this bites untyped or generated definitions.

Definition-time __proto__ property refusal

Section titled “Definition-time __proto__ property refusal”

defineNode() / defineEdge() refuse a schema that declares a property named __proto__ with a ConfigurationError carrying details.conflicts and a nodeType / edgeType key. The name is unstorable, not merely reserved: Zod accepts it in a shape but drops it from every parse result — reporting success even when the field is required — so a value written to it is silently lost.

It is only reachable through a computed key. z.object({ __proto__: … }) written literally sets the shape object’s own prototype instead of creating an entry, while z.object({ ["__proto__"]: z.string() }) yields a shape whose Object.keys really does contain it.

The graph-extension document path refuses the identical declaration with the stable issue code RESERVED_PROPERTY_NAME, at any nesting depth — so a nested object field named __proto__ is refused on the same grounds as a top-level one. Before this, the two authoring paths disagreed about the same field: a typed refusal on the document path, silent data loss on the typed one.

A write guarded by a declared constraint runs its probe and its write under one per-graph mutual exclusion. That fence is transaction-scoped on both dialects (SQLite’s BEGIN IMMEDIATE, PostgreSQL’s pg_advisory_xact_lock), so a backend reporting capabilities.execution.interactiveTransactions: false — Cloudflare D1, drizzle-orm/neon-http, any SQLite backend built with transactionMode: "none" — cannot hold it, and the write is refused rather than run unfenced. Durable Objects are unaffected.

details.constraint names which class needed the fence, because “this backend cannot fence constrained writes” is unusable advice while “your cardinality: 'one' edge cannot be enforced here” is actionable. The suggestion carries the per-class way forward.

details.constraint The write it describes
edgeCardinality Creating or resurrecting an edge whose cardinality is one, unique, or oneActive.
edgeMatchKeyConvergence Endpoint convergence that requires the portable transaction-scoped path: an undeclared dynamic matchOn, constrained cardinality, update or temporal options, derived/custom backends, or schema-aware resurrection of a tombstoned winner. A schema-declared durable matchIdentity removes this fence from eligible live single-item and bulk create/found paths.
nodeDisjointness Creating a node under a kind that participates in a disjointWith axiom. Probed only where a node comes into existence, so deletes and in-place updates are not refused.
nodeUniquenessScope Creating or updating a node under a scope: "kindWithSubClasses" unique that actually expands past the node’s own kind. A scope: "kind" unique is backed by the uniques primary key and needs no fence.

details.graphId names the graph. Unconstrained writes on the same backend are untouched — see Declared constraints require an interactive transaction for what still works there.

CONSTRAINT_TRANSACTION_NOT_WRITE_FENCED is the corresponding refusal for a caller-adopted SQLite transaction whose DEFERRED snapshot became stale before the constrained write could take the writer slot. Roll back that transaction and retry it with BEGIN IMMEDIATE; TypeGraph-owned transactions already use that mode. The refusal happens before the constraint probe, so the write is fenced or refused rather than allowed to rely on a stale decision.

A backend whose capabilities.execution.unitOfWork is "batch" (Cloudflare D1’s batch(), Neon HTTP’s transaction(queries)) fixes every statement before the first one runs and commits them together with no session in between. Every fused write on such a backend — a static batch and a certified atomic program alike — asserts the active schema version inside the very statement that writes, so a stale version writes nothing and the store reports StaleVersionError. See The guard every fused write shares.

A write that needs more than that one guarded statement refuses, but BATCH_WRITE_UNSUPPORTED is not itself a top-level error code: the enforcing gate keeps its own class and code (CONSTRAINT_WRITE_FENCE_UNSUPPORTED, UNSUPPORTED_BACKEND_CAPABILITY, IDENTITY_REQUIRES_ATOMIC_BACKEND, or a plain ConfigurationError for history / revisionTracking / a schema commit) and nests { code: "BATCH_WRITE_UNSUPPORTED", reason } under details.batchRefusal, naming what a closed batch cannot supply:

details.batchRefusal.reason What it needs Raised by
interactive-callback Hold an interactive callback transaction open across several round trips. store.transaction(fn) / store.transactionWithReceipt(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 (CONSTRAINT_WRITE_FENCE_UNSUPPORTED, above)
identity Read and write Operational Identity’s closure across several round trips inside one held transaction. Store construction, or requireAtomicIdentityBackend, when graph.identity is declared
history Hold the per-graph write lock and clock open across a whole write cascade. history: true or revisionTracking: true
schema-commit Hold one transaction across its compare-and-swap read and its activating write. commitSchemaVersion / setActiveVersion

SCHEMA_WRITE_FENCE_UNSUPPORTED — the portable schema-version fence an ineligible write falls back to (see Schema Migrations) — does not carry batchRefusal. It is reached from many fuse failures that are not specific to a batch-tier backend (an ineligible write kind, a tombstone-resurrection write a supplied id falls through to, a derived backend, a provenance mismatch), so it states its plain limitation without guessing which of the reasons above, if any, applies.

capabilities.writeFence resolves one of four write-fence plans a lock site consumes — see Write fence declaration. ConfigurationError codes name the ways a backend’s fence declaration, or its resolved plan, turns out not to cover what a write needs:

details.code Raised when
WRITE_FENCE_DECLARATION_INVALID The declared writeFence fails runtime validation: an unrecognized mechanism string, an unrecognized drain string under mechanism: "advisory", or a drain key present on mechanism: "engine-serialized" / "caller-serialized" (drain applies only to "advisory"). details.field names "mechanism" or "drain"; for an unrecognized value, details.accepted lists the allowed strings. Raised by resolveWriteFencePlan before any plan is shaped — an invalid drain never falls through to behaving like "quiescent".
WRITE_FENCE_SQL_UNAVAILABLE The resolved declaration’s mechanism is "advisory" but the backend’s fenceSql is missing the member that mechanism/drain combination needs to spell (advisoryLockExpression, isolationFactExpression, or, under drain: "table-lock", lockTables) — or, independently of any lock plan, a session isolation-level read (recorded capture’s isolation guard) finds no fenceSql at all. Raised at backend construction for the lock-plan case; at the point of the read for the session-fact case.
RECORDED_CLOCK_REQUIRES_WRITE_FENCE The store is constructed with history: true or revisionTracking: true — TypeGraph-owned recorded-clock allocation — against a backend whose write-fence plan resolves unfenced.
WRITE_FENCE_UNAVAILABLE A resolved plan cannot satisfy what a specific operation needs: either the plan is unfenced outright, or it is a lock plan whose drain is "none" meeting an operation whose requires is "drain". details.operation names the operation and details.requires names which kind of exclusion ("keyed" or "drain") it needed; a drain: "none" refusal also names the drain in the message. "engine-serialized" and "caller-serialized" satisfy either requires value without consulting drain.
ENGINE_NATIVE_RECORDED_TIME_NOT_IMPLEMENTED The backend declares recordedTimeOwnership: "engine-native" and the store is constructed with history: true or revisionTracking: true — TypeGraph still allocates its own recorded clock for those options, so the engine-native path is refused as an interim measure, independently of the write-fence plan. See Recorded-time ownership.
CALLER_SERIALIZED_REFUSES_ADOPTION adoptTransaction was called on a backend whose resolved write-fence plan is caller-serialized. An externally owned transaction’s lifetime cannot be held by the backend’s in-process write-unit queue, so store.withTransaction(externalTx) is refused rather than let its writes silently interleave with the queue’s own. details.member names "adoptTransaction".

RECORDED_CLOCK_REQUIRES_WRITE_FENCE and ENGINE_NATIVE_RECORDED_TIME_NOT_IMPLEMENTED refuse at createStore, never mid-flush, and the message names the exact declaration line to add. WRITE_FENCE_UNAVAILABLE is not a createStore-time check: requireWriteFence is called from every individual lock site (the identity graph lock, the identity-enablement drain, identity DDL, trusted import, contribution DDL, recorded-clock allocation, schema-fence sites, graph-merge provenance), so it fires wherever one of those runs — inside a live transaction, mid-operation, not only at createStore. WRITE_FENCE_SQL_UNAVAILABLE and WRITE_FENCE_DECLARATION_INVALID both refuse earlier, at backend construction for a createSqlBackend-built backend (or, for the session-fact half of WRITE_FENCE_SQL_UNAVAILABLE, at the read that needed it), since they are about the declaration itself rather than what a specific store option or operation requires of it. CALLER_SERIALIZED_REFUSES_ADOPTION fires wherever adoptTransaction is actually called, which is never at createStore time. IDENTITY_REQUIRES_WRITE_FENCE is another write-fence-related code — see the Operational Identity guard codes table above — but is not in this table because it guards identity construction, not recorded-clock allocation.

The in-process queue a writeFence: { mechanism: "caller-serialized" } declaration builds (src/backend/serialized-execution-queue.ts) raises two more ConfigurationError codes, both naming details.subject — the SQLite dialect string for SQLite’s own per-connection queue, or "caller-serialized" for the write-unit queue a caller-serialized declaration builds:

details.code Raised when
SERIALIZED_QUEUE_REENTRANT_SUBMISSION A queued operation was awaited from inside a transaction already running on the same queue — the transaction holds the queue’s execution slot until it completes, so the nested operation could never run. Use the transaction-scoped context (tx.nodes / tx.edges / tx.backend) instead of the root store or backend inside a store.transaction callback, or move the operation outside the transaction.
CALLER_SERIALIZED_REQUIRES_ASYNC_CONTEXT The queue’s reentrancy detection depends on node:async_hooksAsyncLocalStorage, which is unavailable on this runtime (or had not finished loading). A caller-serialized write-fence declaration’s in-process promise depends on that detection actually working, so every submission is refused rather than run without it. SQLite’s own per-connection queue never raises this code: it runs without detection instead of refusing when the context is unavailable.

These codes are not part of RECORDED_CAPTURE_GUARD_CODES — that set is closed to the three codes documented under Recorded-capture guard codes below, and isRecordedCaptureGuardError does not recognize any write-fence code.

Custom backend declarations and capability bundles use stable details.code values when the declared surface disagrees with what TypeGraph can safely execute:

details.code Raised when
CAPABILITY_DECLARATION_CONTRADICTION recursiveTraversal.supported and its reason contradict each other: unsupported without a reason, or supported with a dangling reason.
RECURSIVE_TRAVERSAL_UNSUPPORTED A backend declares recursive traversal unsupported and a recursive query, subgraph read, or historical identity operation needs it. details.operation names the refusing path and details.reason echoes the backend declaration.
CONSTRAINT_CLAIM_SURFACE_MISMATCH The constraintClaims declaration and the claim members implemented by the backend disagree in either direction.
BUNDLE_PORT_SURFACE_MISMATCH A non-claim capability bundle resolves a required member as present, but the backend port used by the operation cannot reach it. Fallback-disposition members degrade through their documented fallback instead of throwing this code.
RECORDED_DDL_CONSTRAINT_NAME_MISMATCH recordedTableDdl names a primary-key constraint for only one of the temporary or final recorded-table name sets.

The recorded-time preview migration also throws UnsupportedBackendCapabilityError with details.capability: "recordedTableDdl" when a legacy schema needs rewriting and the custom backend does not provide its DDL callback. See Migrating Preview Recorded Time and Capability bundles for the corresponding migration and backend-author guidance.

Approximate retrieval with a mismatched metric

Section titled “Approximate retrieval with a mismatched metric”

.similarTo(vector, k, { approximate: true, metric }) is refused with a ConfigurationError when metric differs from the field’s declared metric. An ANN structure is built for one metric — vec0 bakes distance_metric into the virtual table, libSQL’s DiskANN index is built with metric=…, pgvector’s index carries a per-metric operator class — so retrieving by the declared metric and re-scoring under the override would return the declared metric’s neighbors wearing the override’s scores. The two options state something that cannot both hold, so the option is refused rather than downgraded to an exact scan behind the caller’s back.

details carries nodeKind, fieldPath, requestedMetric, declaredMetric, and indexType; there is no stable details.code, so match by class and details. A slot declared indexType: "none" is not refused — there is no ANN structure to be bound to a metric, and the opt-in compiles to the exact scan, a degradation stated on the approximate option itself. A mismatched metric with no approximate is not refused on the query builder either; store.search.vector and store.search.hybrid refuse every mismatched override on their own broader rule. See Approximate retrieval.

Durable edge match identity uses stable ConfigurationError detail codes:

details.code Meaning
EDGE_MATCH_IDENTITY_VALUE_NOT_SCALAR A declared identity field cannot be represented as a portable JSON scalar, or an untyped runtime value violated that declaration.
EDGE_MATCH_IDENTITY_KEY_TOO_LARGE One complete durable identity tuple exceeds the portable 2,000-byte index budget. Normal import records this against the individual edge; trusted import is atomic and refuses the whole stream.
EDGE_MATCH_IDENTITY_STORAGE_UNAVAILABLE The adapter declares durable identity support, but the database is missing its columns or unique arbiter. Initialize or migrate the schema before serving writes.
EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND Initial adoption needs an atomic empty-kind fence or materialization preflight that the custom backend does not implement.
DURABLE_EDGE_MATCH_IDENTITY_COMMAND_UNSUPPORTED A custom backend declares durable identity support but refuses the authoritative convergence command. TypeGraph fails closed because the portable read-then-write fallback has no equivalent database arbiter.
IMPORT_EDGE_BATCH_RETRY_REQUIRES_SAVEPOINT A durable import batch was refused without savepoint rollback protection, either because the backend is non-transactional or because its root/transaction statement-execution contract cannot serve savepoints. TypeGraph will not retry rows individually because that could double-attribute an already-written prefix.

The last refusal deliberately differs from optional fused-command fallback: the durable identity declaration delegates correctness to a database key, so a backend that claims the feature but refuses its command cannot safely re-enter the dynamic portable path.

findEdgesByHeterogeneousEndpointSet refuses mixed endpoint modes instead of guessing how incident and exact-pair rows should be interpreted:

details.code Meaning
EDGE_HETEROGENEOUS_READ_MIXED_ENDPOINT_MODES The request contains both incident-endpoint rows (without an opposite endpoint) and exact directed-pair rows. Supply an opposite endpoint for every row to request exact-pair matching.
EDGE_HETEROGENEOUS_READ_BIND_BUDGET_EXCEEDED The endpoint set cannot fit within the backend’s bind-parameter budget. Split the request into smaller calls.

Operational Identity lifecycle failures use stable details.code values on ConfigurationError:

details.code Meaning
IDENTITY_REQUIRES_ATOMIC_BACKEND The selected adapter cannot provide the interactive transaction required by identity writes.
IDENTITY_REQUIRES_STATEMENT_EXECUTION The backend cannot execute the raw statements Operational Identity issues internally.
IDENTITY_REQUIRES_WRITE_FENCE Operational Identity was constructed against a backend whose capabilities.writeFence resolves unfenced — declare the capability, matching the engine’s real locking support. See Write-fence declaration codes.
IDENTITY_NOT_ENABLED store.identity, tx.identity, StoreView.identity, or an identity-expanded query option was reached on a graph without identity: { ... } — normally caught at compile time; this is the runtime guard for a widened or any-typed handle.
IDENTITY_STORAGE_MISSING An identity relation disappeared after enablement, or exists without this graph’s fill. Restore ledgers, or recreate and rebuild the derived closure, before serving traffic. details.reason: "unfilled" marks the second case: the separation relation is present but holds no row for this graph while the ledger holds a live different assertion across two distinct identity classes — reopen the Store (the open runs the fill) or run rebuildIdentityClosure(store). A Store handle opened while the relation did not exist keeps failing until it is reopened, which is deliberate: the alternative is a confident “not separated” the moment another graph’s upgrade creates the shared relation.
IDENTITY_UPGRADE_REQUIRES_ATOMIC_DDL The backend cannot publish the derived separation relation’s upgrade — the CREATE and the fill — as one commit, on a graph that owes rows. details.missingPorts names what is absent: schemaWriteTransaction / identityTableDdl on the fenced path, or executeSchemaDdl on the schema-commit path. Refused rather than degraded, because a relation created empty and filled afterwards reads as “nothing is separated” in between. Both bundled Drizzle backends implement all three when transactions are enabled, so this is a custom-backend path.
IDENTITY_ENABLEMENT_PENDING First enablement is pending because autoMigrate is disabled.
IDENTITY_PROFILE_MIGRATION_PENDING A sameIdAcrossKinds change (a breaking foldignore flip, or disabling identity) has not been applied — either it is breaking, or autoMigrate is disabled.
IDENTITY_SCHEMA_MIGRATION_PENDING An identity-relevant ontology change is pending because autoMigrate is disabled.
IDENTITY_SEPARATION_VIOLATION The derived separation relation refused a write that would place both endpoints of a current different assertion in one identity class. The database-level backstop beneath identity validation; reaching it means an earlier guard let a contradiction through.
IDENTITY_TRANSACTION_NOT_WRITE_FENCED SQLite refused an identity write because the enclosing transaction was begun DEFERRED and another connection committed before it could take the writer slot. Only reachable through store.withTransaction(externalTx) / store.withRecordedTransaction(externalTx), where the caller owns the BEGIN — TypeGraph’s own transactions open BEGIN IMMEDIATE and hold the slot from the start. SQLite cannot upgrade a stale snapshot in place, so roll back and re-run the transaction, opening it with BEGIN IMMEDIATE.
IDENTITY_SCHEMA_CONTRADICTION Existing nodes or assertions contradict the proposed identity profile or ontology, or the materialized closure disagrees with the assertions it was derived from. Run rebuildIdentityClosure(store) to recover from a closure mismatch.
IDENTITY_IMPORT_REQUIRES_PROFILE An interchange document carries an identity section but the target graph does not have the profile enabled.
IDENTITY_MERGE_REQUIRES_PROFILE A branch carries identity changes but the merge target graph does not have the profile enabled.
IDENTITY_EXPORT_REQUIRES_TEMPORAL_FIELDS An identity-enabled export explicitly disabled temporal fields. Remove includeTemporal or set it to true; endpoint bounds are required to validate assertion windows on import.
IDENTITY_IMPORT_ID_CONFLICT An imported assertion id already exists in the target ledger identifying different truth (relation, endpoints, or validity window).
RECORDED_IDENTITY_SCHEMA_MISSING A history: true open of an identity-enabled graph could not find the recorded identity relation. Bundled backends provision it, so this is rare there and more likely on a custom backend.

When an unapplied migration’s only breaking change is the identity one, the specific pending code above wins over the generic MigrationError (which is attached as cause); a diff that also breaks nodes, edges, ontology, or indexes raises the generic MigrationError enumerating all of them.

Identity import also raises ValidationError with one of these details.issues[].code values when an interchange document’s identity section fails shape or integrity checks. Each issue carries the offending assertion’s id structurally in details.issues[].assertionId, and importGraph/importGraphStream record these failures as entityType: "identity" entries in result.errors (a self-assertion — IDENTITY_SELF_ASSERTION — included) rather than throwing:

Issue code Meaning
IDENTITY_IMPORT_UNKNOWN_KIND An assertion endpoint names a node kind not in the target graph’s registry.
IDENTITY_IMPORT_PAIR_NOT_NORMALIZED An assertion’s a/b endpoints are not in code-point order.
IDENTITY_STATE_IMPORT_ENDED_ASSERTION A state-mode import (the default) contains an already-ended assertion; use identityMode: "archival" on export to carry ended assertions.
IDENTITY_IMPORT_FUTURE_VALID_FROM An open (current) assertion’s validFrom is in the future, in either import mode.
IDENTITY_IMPORT_FUTURE_VALID_TO An ended assertion’s validTo is in the future.
IDENTITY_IMPORT_INVALID_WINDOW An assertion’s validTo precedes its validFrom.
IDENTITY_IMPORT_ENDED_BY_WITHOUT_END An assertion names an endedBy cause but carries no validTo; only an ended assertion has a cause.
IDENTITY_IMPORT_ENDED_BY_NOT_ENDPOINT An assertion’s endedBy names a node that is not one of its own endpoints; a deletion cascade only ends assertions that touch the deleted node.
IDENTITY_SELF_ASSERTION An assertion’s a and b name the same node.

persistProvenance: true writes to a sidecar graph beside the merge target, and openProvenanceStore refuses any sidecar graph id it cannot prove it owns. Both refusals are ConfigurationErrors with a stable details.code, and both carry details.graphId (the sidecar id) and details.targetGraphId:

details.code Meaning
GRAPH_MERGE_PROVENANCE_ID_COLLISION The sidecar graph id is occupied by something this library did not write. details.reason names which state was found, and the suggestion is specific to it.
GRAPH_MERGE_PROVENANCE_CLAIM_UNFENCED The backend exposes no transactional schema fence (schemaWriteTransaction), so the id’s emptiness check and its ownership-marker write cannot commit as one unit. Not a collision — the id may well be free. An already-owned sidecar still opens on such a backend, so read-only use of an existing sidecar stays available.

The five details.reason values on GRAPH_MERGE_PROVENANCE_ID_COLLISION:

details.reason The state that was found
application-graph The id holds rows (in any per-graph table) or a schema that is not the sidecar’s, so it belongs to an application. Rename the colliding graph or point the merge elsewhere.
empty-legacy-sidecar A pre-marker sidecar with no rows at all, which carries no evidence of authorship and is indistinguishable from an application graph of the same shape.
unupgradeable-legacy-sidecar A pre-marker sidecar whose rows do not verify as provenance this library wrote for this target, so it cannot be upgraded to an owned sidecar.
unowned-exact-schema-graph The current sidecar schema with no ownership marker. Because the marker is written first, this library cannot have produced this state; contents are not consulted, so an empty or provenance-shaped occupant is refused too.
corrupt-ownership-marker A ProvenanceOwner row that is not a valid live claim for this target — soft-deleted, schema-invalid, naming a different target, or stored under a different row id. It is never overwritten or resurrected, because it may be an application’s row.

Under persistProvenance: true these arrive wrapped: the sidecar is opened and claimed before the merge commits, and either code refuses the merge as an InvalidMergeOptionsError (details.option: "persistProvenance", details.provenanceErrorCode echoing the code above, the ConfigurationError as cause) with the target left unmodified. Only transient row-write failures after the commit degrade to a warnings entry.

Interchange serialized-connection guard codes

Section titled “Interchange serialized-connection guard codes”

Two long-lived interchange streams cannot share one serialized database connection: an export snapshot holds a read transaction for the whole stream and a streaming import writes a transaction per chunk on that same connection, so the second one either nests a BEGIN or waits for a slot that never frees. The lease is exclusive — one stream of any kind per connection — so all four pairings refuse with a ConfigurationError rather than hanging:

details.code Raised when
INTERCHANGE_SHARED_SERIALIZED_BACKEND_SNAPSHOT An export snapshot holds the connection, detected through the shared serialized resource the two backend wrappers were marked with.
INTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOT The same condition, reported by the object-identity detector: one SQLite backend is exporting into itself. Worth telling apart because the fix differs — pass a second backend rather than await whatever else is running.
INTERCHANGE_SERIALIZED_IMPORT_IN_PROGRESS A streaming import holds the connection, in either order of discovery.

The code names what holds the connection; details.requested and details.heldBy (each "export-snapshot" or "import-stream") name which pairing was actually refused, so a same-kind refusal is never reported as something it is not. details.graphId names the graph the refused stream was for.

"import-stream" is the kind of every long-lived import, not only importGraphStream: importGraph holds the lease for the whole call, and trustedImportGraph / trustedImportGraphStream hold it for the whole trusted session — so those APIs throw this ConfigurationError as well as their own TrustedImportError. Connections TypeGraph cannot observe are not refused: two clients dialed at one server, or two SQLite handles on one file, are genuinely independent. See Scaling branches and interchange for which drivers are recognized as serialized.

Recognition is a duck-type over the client object, so a serialized driver TypeGraph cannot identify (expo-sqlite, op-sqlite, sqlite-proxy, pg-proxy, Bun SQL, a postgres-js client capped through a string it does not coerce) is left unmarked and its stream pairs are not refused. createSqliteBackend and createPostgresBackend accept a serializedResource declaration for that gap — { mode: "shared", resource: client } — and for the reverse case, { mode: "independent" }, when the detection is wrong for your topology. See Serialized connections.

The declaration is applied or refused, never quietly ignored:

Declaration Outcome
{ mode: "shared", resource } on a connection TypeGraph did not detect, or naming the client it did detect The named object is the serialized resource; two backends naming the same object are one connection
{ mode: "shared", resource } naming a different object than the one detected ConfigurationError (code: "CONFIGURATION_ERROR") from the factory, with details.reason: "serialized-resource-conflict" and details.declaredKind / details.detectedKind naming what each side was
{ mode: "independent" } Honored, whatever was detected — the documented escape hatch

The conflict is refused rather than resolved because two wrappers over one connection given two different sentinels would stop being seen as a pair, which is precisely the refusal this guard exists to make.

The two *Kind details are constructor names ("Database", "BoundPool"), not the handles themselves: details is what toLogString() serializes, and a driver handle there would print whatever that driver stores — a pg.Pool keeps its connectionString, password included — into your logs.

{ mode: "independent" } lifts the shared-resource arm between two distinct backend objects. It does not lift INTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOT: one SQLite backend exporting into itself holds the one snapshot transaction its own import writes through, which is a fact about a single handle rather than a claim about connection topology. Pass a second backend for that case. That surviving refusal is SQLite-only, so on PostgreSQL a backend declared independent exporting into itself is not refused either — a client that hands out independent connections is exactly what the declaration claims.

An export stream whose signal fires settles with ExportStreamCancelledError (code: "INTERCHANGE_EXPORT_STREAM_ABORTED") rather than a silent end of stream, so a consumer never mistakes a cancelled export for a complete one. It is thrown only after the export has given back everything it took, so receiving it means the connection is already free. What that was depends on the backend: a transactional one rolls back the snapshot and releases the connection’s stream lease; one without transactions held neither and simply abandons its remaining reads, its delivered chunks never having been a single snapshot. The message says which. details.graphId names the exported graph and cause carries the signal’s own reason when the caller supplied one. A signal that is already aborted refuses the export before any transaction is opened. See Cancelling an export.

An exportGraphStream configured with idleTimeoutMs settles with ExportStreamIdleTimeoutError (code: "INTERCHANGE_EXPORT_STREAM_IDLE_TIMEOUT") when its consumer does not request another chunk within that bound. The timeout measures only the interval after a chunk is yielded; time spent waiting for the backend to produce the next chunk does not count. details.graphId identifies the graph and details.idleTimeoutMs carries the configured bound. As with explicit cancellation, a transactional export rolls its snapshot back and releases its stream lease before the error is delivered; a non-transactional export held neither and abandons its remaining reads. See Cancelling an export.

ConfigurationError is intentionally open-shaped, but the guards that fire on a history: true / revisionTracking: true store carry a stable, branchable details.code so a portable caller does not have to substring-match the message. The three codes are exported as a set, RECORDED_CAPTURE_GUARD_CODES, and reachable through the isRecordedCaptureGuardError type guard:

details.code Raised when
RECORDED_CAPTURE_REQUIRES_CALLBACK_TRANSACTION store.withTransaction(externalTx) on a history-enabled store — it has no flush point before the caller commits. Use store.withRecordedTransaction(externalTx, fn). (Also a compile error on an AdapterHistoryStore.)
RECORDED_CAPTURE_RAW_SQL_DISABLED A raw SQL escape (tx.sql, backend.executeStatement / executeDdl) on a history-enabled store, where it would bypass recorded-time capture.
REVISION_TRACKING_RAW_SQL_DISABLED The same raw SQL escape on a revision-tracked store, where it would bypass the revision anchor.

Typed code cannot call withTransaction on an AdapterHistoryStore; use withRecordedTransaction directly. The runtime code remains useful at JavaScript and deliberately untyped boundaries. If one of those boundaries throws, isRecordedCaptureGuardError(error, "RECORDED_CAPTURE_REQUIRES_CALLBACK_TRANSACTION") narrows both the error and its details.code without message matching.

Pass a specific code to narrow to one guard, or omit it to match any. The guard narrows error to a ConfigurationError whose details.code is the passed RecordedCaptureGuardCode (or the full union when no code is given), so no untyped details spelunking is needed.

This composes with tx.sqlAvailability: the discriminant tells a caller why tx.sql is unusable ahead of time ("history" / "revisionTracking" vs. "unavailable" for a backend with no transactions), while the guard code identifies a guard that has already thrown. Between them, “history capture forbids raw SQL here” and “this backend has no transactions” (which carries no guard code) are cleanly distinguishable without catching-and-string-matching.

Thrown when the database schema doesn’t match the expected graph definition.

try {
const [store] = await createStoreWithSchema(graph, backend);
} catch (error) {
if (error instanceof SchemaMismatchError) {
console.log(error.category); // "system"
console.log(error.details);
// { graphId: "my-graph", expectedHash: "<hash>", actualHash: "<hash>" }
console.log(error.suggestion);
// "Run migrations to update the database schema..."
}
}

Thrown when schema migration fails due to breaking changes that require manual intervention.

The details.reason value "edge-match-identity-rekey" means a populated edge kind changed or newly adopted its durable match identity. Existing rows cannot be assigned new identity keys without choosing how conflicts converge. Export the affected edges, hard-delete them, apply the schema migration, then reimport them so TypeGraph materializes and arbitrates the new durable keys.

try {
const [store] = await createStoreWithSchema(graph, backend);
} catch (error) {
if (error instanceof MigrationError) {
console.log(error.category); // "system"
console.log(error.details);
// { graphId: "my-graph", fromVersion: 3, toVersion: 4, reason: "Removed required field 'email' from Person" }
console.log(error.suggestion);
// "Review the breaking changes and perform manual migration if needed..."
}
}

Thrown by zero-DDL verified and graph-template entry points when the deployment-wide physical TypeGraph schema has not been adopted to the version required by the running library. This is separate from MigrationError, which describes one graph’s serialized schema evolution.

try {
const [store] = await createVerifiedStore(graph, backend);
} catch (error) {
if (error instanceof BaseSchemaMigrationError) {
console.log(error.details);
// {
// installedVersion: undefined,
// requiredVersion: 1,
// reason: "missing"
// }
}
}

reason is "missing", "stale", or "newer". For missing or stale storage, run createStoreWithSchema() or createAdapterStoreWithSchema() once under a DDL-capable role, or apply the published external base-schema migration. A newer marker requires a TypeGraph release that supports that version.

Thrown when using a query predicate that isn’t supported by the current backend.

// Using vector similarity on a backend without vector support:
try {
await store
.query()
.from("Document", "d")
.whereNode("d", (d) => d.embedding.similarTo(queryVector, 10))
.execute();
} catch (error) {
if (error instanceof UnsupportedPredicateError) {
console.log(error.category); // "system"
console.log(error.suggestion);
// "Use a backend that supports this predicate, or rewrite the query..."
}
}

Thrown when a statement reaches a transaction-scoped backend after its transaction boundary has already returned.

A transaction pins one database connection, which carries one statement at a time. When store.transaction(...) resolves or rejects, the driver emits COMMIT or ROLLBACK on that connection and hands it back to the pool. Any statement still in flight then has nowhere safe to go — it would execute inside somebody else’s transaction — so TypeGraph refuses it.

The usual source is a callback that lets work escape it. Promise.all rejects on its first rejection while its siblings keep running:

await store.transaction(async (tx) => {
// If `a` fails, `b`'s remaining statements are orphaned.
await Promise.all([tx.nodes.Doc.create(a), tx.nodes.Doc.create(b)]);
});

You will normally never see this error: Promise.all has already rejected with the original failure and discards the orphan’s. It surfaces only if you await the orphaned promise yourself. To avoid orphaning writes at all, use Promise.allSettled and inspect the results, or await the writes in sequence.

adoptTransaction() never closes its queue — only the caller knows when their transaction ends — so this error cannot arise there. It remains the caller’s job to await every graph write before committing.

Thrown when a transaction was aborted by a serialization failure or deadlock on every attempt available to it. details.operation names the transaction that failed, details.attempts the number tried, and cause is the last attempt’s driver error — PostgreSQL’s own protocol for both conditions is to re-run the whole transaction from the top, which is what this error reports as exhausted.

store.transaction() and store.transactionWithReceipt() raise it with attempts: 1 for a conflict on their single attempt; passing retry: { attempts } (see Retrying on conflict) raises it only once every attempt has conflicted. Graph-merge’s commit paths raise MergeError on the same exhaustion, with a TransactionConflictError as its cause.

try {
await store.transaction(fn, { retry: { attempts: 3 } });
} catch (error) {
if (error instanceof TransactionConflictError) {
console.log(error.details.attempts); // 3
console.log(error.cause); // the last driver error
}
}

The serialization covers TypeGraph’s own statements, not tx.sql. The raw Drizzle handle you get for writing your own relational tables in the same transaction shares the one pinned connection but bypasses the queue. Running a raw statement concurrently with a graph write — or with another raw statement — races two queries on that connection (the overlap pg@9 removes), and the boundary cannot drain a raw statement it never saw. Await each tx.sql statement before the next write.

TypeGraph provides utility functions for common error handling patterns:

import {
isTypeGraphError,
isUserRecoverable,
isConstraintError,
isSystemError,
getErrorSuggestion,
} from "@nicia-ai/typegraph";
try {
await store.nodes.Person.create(data);
} catch (error) {
if (!isTypeGraphError(error)) {
// Not a TypeGraph error, handle differently
throw error;
}
// Get suggestion regardless of error type
const suggestion = getErrorSuggestion(error);
if (isUserRecoverable(error)) {
// User can fix this by providing different input
return {
error: error.toUserMessage(),
suggestion,
};
}
if (isConstraintError(error)) {
// Business rule violation
return {
error: "This operation violates a constraint",
details: error.details,
};
}
if (isSystemError(error)) {
// Infrastructure/configuration issue
console.error(error.toLogString());
throw error;
}
}
import {
ValidationError,
NodeNotFoundError,
DisjointError,
} from "@nicia-ai/typegraph";
try {
await store.nodes.Person.create(data);
} catch (error) {
if (error instanceof ValidationError) {
// Handle validation failure with contextual details
return {
error: "Invalid data",
issues: error.details.issues,
entity: error.details.kind,
};
}
if (error instanceof DisjointError) {
// Handle constraint violation
return { error: "ID already used by different type" };
}
throw error; // Re-throw unexpected errors
}
try {
await store.nodes.Person.update(id, data);
} catch (error) {
if (error instanceof TypeGraphError) {
switch (error.code) {
case "NODE_NOT_FOUND":
return { error: "Person not found" };
case "VALIDATION_ERROR":
return { error: "Invalid data", issues: error.details.issues };
default:
throw error;
}
}
throw error;
}
try {
await store.transaction(async (tx) => {
const person = await tx.nodes.Person.create({ name: "Alice" });
const company = await tx.nodes.Company.create({ name: "Acme" });
await tx.edges.worksAt.create(person, company, { role: "Engineer" });
});
} catch (error) {
// Transaction is automatically rolled back on any error
if (error instanceof ValidationError) {
console.log("Validation failed, transaction rolled back");
console.log("Failed on:", error.details.kind, error.details.operation);
}
throw error;
}

For library authors or advanced use cases, validation utilities are available from the schema sub-export:

import {
validateNodeProps,
validateEdgeProps,
wrapZodError,
createValidationError,
} from "@nicia-ai/typegraph/schema";
// Validate node properties with full context
const validated = validateNodeProps(PersonSchema, inputData, {
kind: "Person",
operation: "create",
});
// Wrap a Zod error with TypeGraph context
try {
schema.parse(data);
} catch (zodError) {
throw wrapZodError(zodError, {
entityType: "node",
kind: "Person",
operation: "update",
id: "person-123",
});
}
Code Error Class Category Description
VALIDATION_ERROR ValidationError user Schema validation failed
DISJOINT_ERROR DisjointError constraint Disjointness constraint violated
IDENTITY_CONTRADICTION IdentityContradictionError constraint Identity mutation would make the assertion ledger contradictory
IDENTITY_VALIDITY_FUTURE_START IdentityValidityWindowError user Identity assertion starts after the operation clock
IDENTITY_VALIDITY_FUTURE_END IdentityValidityWindowError user Identity assertion ends after the operation clock
IDENTITY_VALIDITY_INVERTED IdentityValidityWindowError user Identity assertion ends before it starts
IDENTITY_VALIDITY_OPEN_WINDOW_CONFLICT IdentityValidityWindowError constraint A different open window already represents the current semantic pair
IDENTITY_ENDPOINT_VALIDITY IdentityEndpointValidityError constraint An endpoint does not cover the explicit assertion window
GRAPH_MERGE_IDENTITY_CONFLICT IdentityMergeConflictError system Branches carry opposing identity truth
GRAPH_MERGE_CONSTRAINT_CONFLICT MergeConstraintConflictError constraint The resolved merge would violate a store constraint
ENDPOINT_ERROR EndpointError constraint Invalid edge endpoint types
ENDPOINT_PAIR_ERROR EndpointPairError constraint Undeclared source/target combination
CARDINALITY_ERROR CardinalityError constraint Cardinality constraint violated
UNIQUENESS_VIOLATION UniquenessError constraint Uniqueness constraint violated
EDGE_MATCH_IDENTITY_CONFLICT EdgeMatchIdentityConflictError constraint A direct edge write collided with its declared endpoint/property identity
NODE_NOT_FOUND NodeNotFoundError user Referenced node doesn’t exist
EDGE_NOT_FOUND EdgeNotFoundError user Referenced edge doesn’t exist
KIND_NOT_FOUND KindNotFoundError user Unknown node/edge type
ENDPOINT_NOT_FOUND EndpointNotFoundError user Edge endpoint node doesn’t exist
RESTRICTED_DELETE RestrictedDeleteError constraint Delete blocked by existing edges
CONFIGURATION_ERROR ConfigurationError system Invalid configuration
SCHEMA_MISMATCH SchemaMismatchError system Database schema mismatch
MIGRATION_ERROR MigrationError system Migration failed
BASE_SCHEMA_MIGRATION_REQUIRED BaseSchemaMigrationError system Deployment-wide base storage requires privileged adoption
UNSUPPORTED_PREDICATE UnsupportedPredicateError system Predicate not supported
UNSUPPORTED_BACKEND_CAPABILITY UnsupportedBackendCapabilityError user The backend does not advertise a capability the call needs. details.capability names it — for example vector.searchFrontierTuning for efSearch on any SQLite vector or hybrid search, where the engine has no per-search ANN frontier, with details.reason naming the limitation
INTERCHANGE_EXPORT_STREAM_ABORTED ExportStreamCancelledError user An export stream’s signal fired, after the export gave back everything it took. On a transactional backend that is the snapshot transaction and the connection’s stream lease; on one without transactions the export held neither and simply abandoned its remaining reads. The message says which
INTERCHANGE_EXPORT_STREAM_IDLE_TIMEOUT ExportStreamIdleTimeoutError user An export stream’s consumer left a delivered chunk unacknowledged past its configured idleTimeoutMs; the export settled its snapshot and lease before reporting the timeout
TRANSACTION_CONFLICT TransactionConflictError system A transaction was aborted by a serialization failure or deadlock on every attempt available to it. details.attempts is the number tried; cause is the last driver error