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.
Error Categories
Section titled “Error Categories”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
Section titled “Base Error”TypeGraphError
Section titled “TypeGraphError”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 |
Validation Errors
Section titled “Validation Errors”ValidationError
Section titled “ValidationError”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..." }}INVERTED_VALIDITY_WINDOW
Section titled “INVERTED_VALIDITY_WINDOW”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.
IMMUTABLE_VALIDITY_LOWER_BOUND
Section titled “IMMUTABLE_VALIDITY_LOWER_BOUND”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
validFromis stored — that is the way to give a row a different lower bound. getOrCreateByEndpointsreturning an existing edge. That branch performs no write, sovalidFrom/validTodescribe the row to create if none is found.clearValidTois refused on a live return-mode match because it names a mutation; useifExists: "update".- A node upsert or endpoint-matched edge update with
onImmutableLowerBound: "preserve". This explicitly treatsvalidFromas create/resurrection-only input. A live-row update keeps its stored lower bound while still applying props andvalidTo; the default remains"refuse"so an unqualified bound is never silently dropped. Edge updates use the policy withifExists: "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.
ENTITY_ALREADY_EXISTS
Section titled “ENTITY_ALREADY_EXISTS”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.
DisjointError
Section titled “DisjointError”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..." }}IdentityContradictionError
Section titled “IdentityContradictionError”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." }}Identity validity errors
Section titled “Identity validity errors”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.
IdentityMergeConflictError
Section titled “IdentityMergeConflictError”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;}MergeConstraintConflictError
Section titled “MergeConstraintConflictError”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.
Merge plan and evidence errors
Section titled “Merge plan and evidence errors”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().
EndpointError
Section titled “EndpointError”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..." }}EndpointPairError
Section titled “EndpointPairError”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.
CardinalityError
Section titled “CardinalityError”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..." }}UniquenessError
Section titled “UniquenessError”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..." }}EdgeMatchIdentityConflictError
Section titled “EdgeMatchIdentityConflictError”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.
Not Found Errors
Section titled “Not Found Errors”NodeNotFoundError
Section titled “NodeNotFoundError”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..." }}EdgeNotFoundError
Section titled “EdgeNotFoundError”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..." }}KindNotFoundError
Section titled “KindNotFoundError”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..." }}EndpointNotFoundError
Section titled “EndpointNotFoundError”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..." }}Delete Errors
Section titled “Delete Errors”RestrictedDeleteError
Section titled “RestrictedDeleteError”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..." }}Configuration Errors
Section titled “Configuration Errors”ConfigurationError
Section titled “ConfigurationError”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
wherecallback that does not return a predicate —detailscarrieskindandconstraintName. - A predicate naming a field the kind’s schema does not declare —
detailsaddsfieldanddeclaredFields. - A
whereclause 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) —detailscarrieskindandconstraintName. Refused rather than left unvalidated, because skipping the check silently would disable this guard for exactly the untyped callers it exists for. A plainunique: [{ 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.
CONSTRAINT_WRITE_FENCE_UNSUPPORTED
Section titled “CONSTRAINT_WRITE_FENCE_UNSUPPORTED”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.
BATCH_WRITE_UNSUPPORTED
Section titled “BATCH_WRITE_UNSUPPORTED”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.
Write-fence declaration codes
Section titled “Write-fence declaration codes”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.
Caller-serialized queue codes
Section titled “Caller-serialized queue codes”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_hooks’ AsyncLocalStorage, 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.
Backend capability declaration codes
Section titled “Backend capability declaration codes”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 guard codes
Section titled “Durable edge match identity guard codes”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.
Heterogeneous edge-read guard codes
Section titled “Heterogeneous edge-read guard codes”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 guard codes
Section titled “Operational Identity guard codes”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 fold↔ignore 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. |
Merge provenance sidecar codes
Section titled “Merge provenance sidecar codes”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.
Declaring a connection the driver hides
Section titled “Declaring a connection the driver hides”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.
ExportStreamCancelledError
Section titled “ExportStreamCancelledError”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.
ExportStreamIdleTimeoutError
Section titled “ExportStreamIdleTimeoutError”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.
Recorded-capture guard codes
Section titled “Recorded-capture guard codes”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.
SchemaMismatchError
Section titled “SchemaMismatchError”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..." }}MigrationError
Section titled “MigrationError”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..." }}BaseSchemaMigrationError
Section titled “BaseSchemaMigrationError”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.
Query Errors
Section titled “Query Errors”UnsupportedPredicateError
Section titled “UnsupportedPredicateError”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..." }}Transaction Errors
Section titled “Transaction Errors”TransactionClosedError
Section titled “TransactionClosedError”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.
TransactionConflictError
Section titled “TransactionConflictError”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.
Error Handling Patterns
Section titled “Error Handling Patterns”Using Error Utilities
Section titled “Using Error Utilities”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; }}Catch Specific Errors
Section titled “Catch Specific Errors”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}Check Error Codes
Section titled “Check Error Codes”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;}Transaction Error Handling
Section titled “Transaction Error Handling”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;}Contextual Validation Utilities
Section titled “Contextual Validation Utilities”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 contextconst validated = validateNodeProps(PersonSchema, inputData, { kind: "Person", operation: "create",});
// Wrap a Zod error with TypeGraph contexttry { schema.parse(data);} catch (zodError) { throw wrapZodError(zodError, { entityType: "node", kind: "Person", operation: "update", id: "person-123", });}Error Codes Reference
Section titled “Error Codes Reference”| 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 |