Graph Interchange
TypeGraph provides a standardized interchange format for importing and exporting graph data. Use it for:
- Backing up and restoring graph data
- Migrating data between environments
- Exchanging data with external systems
Quick Start
Section titled “Quick Start”import { importGraph, exportGraph, GraphDataSchema } from "@nicia-ai/typegraph/interchange";
// Export your graphconst backup = await exportGraph(store);
// Import into another storeconst result = await importGraph(targetStore, backup, { onConflict: "update", onUnknownProperty: "strip",});
console.log(`Imported ${result.nodes.created} nodes, ${result.edges.created} edges`);Interchange Format
Section titled “Interchange Format”The interchange format is a JSON structure validated by Zod schemas. You can use
GraphDataSchema to validate data before import, or export the schema as JSON
Schema for API documentation.
import { GraphDataSchema } from "@nicia-ai/typegraph/interchange";
// Validate incoming dataconst validated = GraphDataSchema.parse(jsonData);
// Export as JSON Schema for API docsimport { toJSONSchema } from "zod";const jsonSchema = toJSONSchema(GraphDataSchema);Format Structure
Section titled “Format Structure”interface GraphData { formatVersion: "2.0"; exportedAt: string; // ISO datetime source: { type: "typegraph-export" | "external"; // Additional source-specific fields }; nodes: Array<{ kind: string; id: string; properties: Record<string, unknown>; validFrom?: string | null; validTo?: string; meta?: { version?: number; createdAt?: string; updatedAt?: string; }; }>; edges: Array<{ kind: string; id: string; from: { kind: string; id: string }; to: { kind: string; id: string }; properties: Record<string, unknown>; validFrom?: string | null; validTo?: string; meta?: { createdAt?: string; updatedAt?: string; }; }>; identity?: { profile: "typegraph-identity-v1"; mode: "state" | "archival"; assertions: Array<{ id: string; relation: "same" | "different"; a: { kind: string; id: string }; b: { kind: string; id: string }; validFrom: string; validTo?: string; }>; };}validFrom has three states: the key absent means it wasn’t requested
(includeTemporal: false, the default) — import defaults it to the
import’s own creation timestamp, unless the record also states a validTo
at or before that instant, in which case it is imported with no lower bound
(“ended at T, start unknown”) rather than one past its own end. An
explicit null means the source row is confirmed to have no lower bound
(open-left validity) — import preserves that instead of re-stamping it. A
string is an explicit value, carried through unchanged.
Format Version Compatibility
Section titled “Format Version Compatibility”Exports always write formatVersion: "2.0". The read side — both
importGraph/importGraphStream and GraphDataSchema.parse — additionally
accepts "1.0". A 1.0 document is structurally a valid 2.0 document: the only
2.0 change is the additive optional identity section, so pre-existing 1.0
exports validate and import unchanged. You never need to rewrite the version
field of an older backup; validation and import handle both.
Exporting Data
Section titled “Exporting Data”Use exportGraph to serialize your graph data:
import { exportGraph } from "@nicia-ai/typegraph/interchange";
// Export everythingconst fullExport = await exportGraph(store);
// Export specific node kindsconst peopleOnly = await exportGraph(store, { nodeKinds: ["Person", "Organization"],});
// Export specific edge kindsconst relationshipsOnly = await exportGraph(store, { edgeKinds: ["worksAt", "knows"],});
// Include metadata (version, timestamps)const withMeta = await exportGraph(store, { includeMeta: true,});
// Include temporal fields (validFrom, validTo)const withTemporal = await exportGraph(store, { includeTemporal: true,});
// Include soft-deleted recordsconst withDeleted = await exportGraph(store, { includeDeleted: true,});
// Identity-enabled graphs export current assertions by default.// Include ended assertion history explicitly:const archival = await exportGraph(store, { identityMode: "archival",});
// A self-contained archive pairs archival identity with includeDeleted:const selfContainedArchive = await exportGraph(store, { identityMode: "archival", includeDeleted: true,});Archival identity and soft-deleted endpoints: identityMode: "archival"
also exports ended assertions, and an ended assertion can reference an
endpoint that was later soft-deleted. A default export (includeDeleted: false) joins every assertion against its endpoints’ live rows, so an
assertion touching a soft-deleted endpoint is silently dropped from the
export — not carried with a dangling reference. This is silent archive
loss, not a dangling-endpoint problem. When the archive must stand alone
(backup, cold storage), pair it with includeDeleted: true so those
assertions and their endpoints travel with it.
That pairing has its own honest trade-off: the interchange format has no
deletedAt field, so a node included only because of includeDeleted: true
carries no record that it was deleted. Re-importing that archive resurrects
the node as live. Choose deliberately: without includeDeleted, a
backup silently loses soft-deleted endpoints and the assertions referencing
them; with it, those nodes come back alive on restore.
On import, every ended assertion’s endpoints must exist as node rows in the
target (soft-deleted rows qualify) — historical reads conduct identity
through ended assertions, so an endpoint that never existed would become a
phantom bridge joining real nodes at past coordinates. The store’s own
exports satisfy this by construction; a hand-built document that fails it is
recorded as an entityType: "identity" entry in result.errors.
Export Options
Section titled “Export Options”| Option | Type | Default | Description |
|---|---|---|---|
nodeKinds |
string[] |
all | Filter to specific node types |
edgeKinds |
string[] |
all | Filter to specific edge types |
includeMeta |
boolean |
false |
Include version and timestamps |
includeTemporal |
boolean |
false |
Include validFrom/validTo fields |
includeDeleted |
boolean |
false |
Include soft-deleted records |
identityMode |
"state" | "archival" |
"state" |
Export current identity assertions, or current plus ended assertions |
signal |
AbortSignal |
none | Cancel the export: roll its snapshot transaction back and release the connection. See Cancelling an export |
exportGraphStream also accepts idleTimeoutMs, a positive integer with no
default. It bounds how long a delivered chunk may remain unacknowledged before
the stream settles itself. The clock stops as soon as the consumer requests the
next chunk, so a slow database read does not count as consumer idleness.
Round-trip caveat: with the default includeTemporal: false, exported
records carry no validFrom/validTo. On import, an omitted validFrom
defaults to the import’s own creation timestamp (a born-already-ended record
is the exception noted above, and keeps no lower bound) — so a plain
exportGraph + importGraph round trip does not reproduce the
source’s original valid-time window; every imported record becomes valid
from import time forward. Pass includeTemporal: true on export when the
clone needs to match the source’s asOf behavior exactly (this is what
branch() does internally).
Identity-enabled graphs are the exception: their exports default temporal
fields on, because assertion windows cannot be validated against endpoints
without the endpoint bounds. Explicit includeTemporal: false is refused for
those graphs.
Repair a legacy graph before exporting it. A row an older library version
stored with a backwards window (valid_from > valid_to) exports as it is stored
and is then refused per row on re-import, because the import validates the
stated pair — so an unrepaired graph does not round-trip. Run
repairInvertedValidityWindows
first; it normalizes those rows to the open-left shape import accepts.
includeTemporal: true with onConflict: "update": an update leg sends the
document’s validTo and never its validFrom, because a live row’s lower bound
is history. A document whose validFrom names a different instant than the
target row holds is therefore stating a bound the import will not apply, and that
row is reported as a per-row error carrying
IMMUTABLE_VALIDITY_LOWER_BOUND
rather than updated under a bound it ignored. This is reachable whenever a
temporal export is replayed over rows that were created separately — the same
document imported into a fresh graph creates those rows with their stated bounds
and is unaffected. To update props over existing rows from a temporal export,
either omit validFrom from the update document, export with
includeTemporal: false, or import into a fresh graph and swap it in.
Cancelling an export
Section titled “Cancelling an export”On a backend reporting capabilities.execution.interactiveTransactions, an export holds one
repeatable-read snapshot transaction for its whole life, and on a
single-connection backend it holds that connection’s exclusive
interchange-stream lease with it. (A backend without transactions — SQLite
transactionMode: "none", the session-less HTTP Postgres drivers — opens
neither: its export paginates statement by statement, so a write committed
mid-stream can appear in the pages that follow. That is a declared capability
gap, not something the stream papers over.) Every cooperative exit gives both back,
because each one runs the stream’s finally: break or throw out of a for await, and an explicit iterator.return().
A consumer that pulls next() and then simply drops the iterator has no
cooperative exit. Async-generator finally blocks do not run on garbage
collection, so that snapshot transaction stays open for the life of the process
— and on a serialized connection every later export and every later import is
then refused for a stream nobody is reading. If you might abandon an iterator,
pass a signal or configure an idle timeout:
const controller = new AbortController();const iterator = exportGraphStream(store, { batchSize: 1000, signal: controller.signal, idleTimeoutMs: 30_000,})[Symbol.asyncIterator]();
try { for (;;) { const next = await Promise.race([ iterator.next(), deadline(30_000), // resolves to a sentinel, leaving the pull in flight ]); if (next === TIMED_OUT) { // Do NOT just walk away: this is the leak. Aborting rolls the snapshot // back and frees the connection. controller.abort(new Error("export deadline exceeded")); break; } if (next.done === true) break; await write(next.value); }} finally { controller.abort();}Aborting rejects the pull that is in flight — and any later pull from a consumer
that walked away and came back — with
ExportStreamCancelledError, carrying the
signal’s own reason as cause, so a cancelled export is never mistaken for a
complete one. The message states what was actually settled: a snapshot rolled
back and a connection released on a transactional backend, or merely abandoned
reads on one that never held either. Aborting a signal before the first pull refuses the export
outright: no transaction is opened and no lease claimed. Aborting one that has
already finished does nothing, so a single controller can safely span a whole
job. exportGraph accepts signal too — there it simply makes the call reject
instead of running to completion.
When idleTimeoutMs expires, a later pull rejects with
ExportStreamIdleTimeoutError, with code
"INTERCHANGE_EXPORT_STREAM_IDLE_TIMEOUT". Its details.graphId and
details.idleTimeoutMs identify the stream and configured bound. The timeout is
stream-only: exportGraph owns and promptly advances its internal consumer, so
it accepts signal but not idleTimeoutMs. The option has no default because a
stream may intentionally spend an unbounded amount of time processing a chunk;
callers that cannot choose a safe idle bound should retain an AbortController
and abort on their own job deadline instead.
There is deliberately no garbage-collection fallback. A FinalizationRegistry
cannot close this gap: any cleanup state able to settle an abandoned stream has
to reach the stream’s internals, and a registry holds its state strongly, so
doing so would keep the abandoned stream reachable and the finalizer would never
run. Explicit cancellation and the idle timeout are the mechanisms.
Importing Data
Section titled “Importing Data”Use importGraph to load data into a store:
import { importGraph } from "@nicia-ai/typegraph/interchange";
const result = await importGraph(store, data, { onConflict: "update", onUnknownProperty: "strip", validateReferences: true, batchSize: 1000,});
if (result.success) { console.log(`Created: ${result.nodes.created} nodes, ${result.edges.created} edges`); console.log(`Updated: ${result.nodes.updated} nodes, ${result.edges.updated} edges`); console.log(`Skipped: ${result.nodes.skipped} nodes, ${result.edges.skipped} edges`); console.log(`Identity: ${result.identity.created} created, ${result.identity.skipped} skipped`);} else { console.error("Import had errors:", result.errors);}Durable edge match identities during import
Section titled “Durable edge match identities during import”The target graph declaration, not the interchange document, determines an
edge’s durable matchIdentity. After validating and normalizing an incoming
edge’s properties, normal import builds the same canonical endpoint/property
key used by collection creates and writes it with the edge row. An import
therefore cannot bypass convergence by choosing a new edge id.
An incoming create whose durable identity is already owned by a different row,
or was claimed by an earlier edge in the same import slice, is recorded as a
per-edge error using EdgeMatchIdentityConflictError. This decision is
separate from onConflict, which handles an existing row with the incoming
edge’s own id; skip and update do not authorize taking another row’s durable
identity.
Bundled backends discover existing owners with set-oriented exact endpoint-pair
reads and insert claimless durable slices with bind-budgeted,
conflict-arbitrated batch statements. A slice that also needs cardinality
claims remains atomic. If an exceptional batch refusal cannot identify the
losing row, TypeGraph rolls the slice back to a savepoint and retries its rows
individually so result.edges.created and result.errors remain honest. A
custom or non-transactional backend that cannot prove that rollback refuses
the import with IMPORT_EDGE_BATCH_RETRY_REQUIRES_SAVEPOINT; retrying a
possibly committed prefix could otherwise double-count or misattribute rows.
When the document carries an identity section, result.identity reports
{ created, skipped } counts for imported assertions (skipped covers an
exact re-import of an assertion that already exists under the same id).
A rejected assertion — an unknown endpoint, a contradiction against the
target’s existing identity truth, or a reused assertion id that names
different truth — is recorded in result.errors with entityType: "identity", kind set to the assertion’s relation ("same" or
"different"), and id set to the assertion id, mirroring how node/edge
errors carry kind/id.
Partial-commit caveat: identity assertions are applied one at a time, and
a mid-batch failure (a contradiction or id conflict partway through the
identity.assertions array) stops the identity import but does not roll back
the assertions already applied before it — they remain committed. They are
not reflected in result.identity.created, since that count is only
reported on success; the count under-reports rather than invents a number for
committed-but-unaccounted work. The failure that stopped the batch is the one
error entry you see in result.errors.
Import Options
Section titled “Import Options”| Option | Type | Default | Description |
|---|---|---|---|
onConflict |
"skip" | "update" | "error" |
required | How to handle existing entities |
onUnknownProperty |
"error" | "strip" | "allow" |
"error" |
How to handle extra properties |
validateReferences |
boolean |
true |
Verify edge endpoints exist |
batchSize |
number |
1000 |
Batch size for database operations. Each batch pays fixed per-round-trip costs, so undersized batches slow client/server imports; inserts are still split by the driver bind budget internally. |
Trusted initial import
Section titled “Trusted initial import”trustedImportGraph and trustedImportGraphStream are a separate,
intentionally trusted path for loading a fresh dedicated database. They do not
turn off validation on importGraph; they bypass the normal store write
pipeline entirely.
import { trustedImportGraphStream, type GraphInterchangeChunk,} from "@nicia-ai/typegraph/interchange";
async function* chunks(): AsyncIterable<GraphInterchangeChunk> { yield { type: "header", header }; for await (const nodes of readNodeBatches()) { yield { type: "nodes", nodes }; } for await (const edges of readEdgeBatches()) { yield { type: "edges", edges }; }}
const result = await trustedImportGraphStream(store, chunks());console.log(result); // { nodes: 1000000, edges: 5000000 }The contract is deliberately narrow:
- The TypeGraph node and edge tables must be globally empty. A different graph in the same database also makes the database non-empty.
- The caller guarantees property shapes, endpoint existence, edge endpoint types, cardinality, duplicate-free IDs, and duplicate-free durable edge match identities. Only stream ordering and known kind names are checked. Trusted import still derives each declared edge identity and stores it with the row, so a collision reaches the database arbiter and rolls back the complete trusted-import transaction.
- Recorded-time history, revision tracking, node uniqueness constraints,
searchable()fields, andembedding()fields are rejected in this first version because their sidecar writes would otherwise be skipped. - Operational Identity-enabled target stores are rejected with
details.reason === "identity_unsupported"; identity-bearing input is rejected withdetails.reason === "invalid_stream". The trusted session writes only the node and edge relations, so it cannot persist assertions or materialize the derived closure — refusing both cases keeps identity truth from being silently dropped. UseimportGraphStreamfor an export that carries Operational Identity assertions. This restriction does not apply to an edge registration’smatchIdentity, which trusted import materializes in the edge relation itself. - Nodes must precede edges. The
metatimestamps and node version in an interchange row are not restored; the import creates new storage metadata. - The complete stream is one transaction. Data insertion, temporary secondary index removal, index rebuilding, and planner statistics either all commit or all roll back.
- A schema-managed Store acquires and validates its schema-write fence inside that transaction before loading rows. A stale managed import fails before row DML; a raw Store remains explicitly outside the schema-fencing guarantee.
Supported native paths are synchronous prepared-statement SQLite
(better-sqlite3 and Bun SQLite) and transaction-capable PostgreSQL adapters
with raw execution support (including node-postgres, postgres.js, and PGlite).
Remote libSQL/Turso, D1, and HTTP-only PostgreSQL adapters reject the call with
TrustedImportError and details.reason === "backend_unsupported".
Use importGraph/importGraphStream for external or uncertain data, conflict
handling, incremental loads, and any graph with the unsupported features above.
Use collection bulkInsert when the data is trusted but the database is not a
fresh dedicated target.
Conflict Strategies
Section titled “Conflict Strategies”skip - Keep existing data, ignore incoming:
// Useful for incremental imports where you don't want to overwriteawait importGraph(store, data, { onConflict: "skip" });update - Merge incoming data into existing:
// Useful for syncing updates from an external sourceawait importGraph(store, data, { onConflict: "update" });error - Fail if any entity already exists:
// Useful for initial imports where duplicates indicate a problemawait importGraph(store, data, { onConflict: "error" });An edge id held by a different edge
Section titled “An edge id held by a different edge”Edge ids are unique per graph, but the import’s existence probe (getEdge /
getEdges) is keyed on (graph_id, id) alone. So a document edge whose id is
already held by a row with a different immutable identity — its kind or
either of its endpoints — finds that row. That question — is this the same
edge? — is prior to what do we do about the same edge?, so it is answered
before the conflict strategy and all three strategies answer alike: the row
is reported as a per-row entry in result.errors, whose error message is
prefixed INTERCHANGE_EDGE_KIND_CONFLICT and names each component that differs
alongside the value the document stated. The stored row is left untouched.
const result = await importGraph(store, data, { onConflict: "update" });const identityConflicts = result.errors.filter((entry) => entry.error.startsWith("INTERCHANGE_EDGE_KIND_CONFLICT"),);One prefix covers the whole class rather than a second one for endpoint
mismatches: the condition is a single fact and the recovery is a single action,
and a caller that had to match two prefixes to catch one condition would
eventually match only one. The token still reads …_KIND_CONFLICT because it is
the published, branchable string; it now covers every identity component.
ImportError carries no code field, so the message prefix is the branchable
token — the same CODE: message idiom the validity-window import refusals use.
Give the incoming edge a distinct id, or import it under the identity the stored
row already carries.
Previously both non-error strategies were silent about this: update wrote
the incoming edge’s properties onto the other row with nothing in
result.errors, and skip counted the document’s edge as already present when
no matching edge existed anywhere — so it was never created and never reported.
Comparing kind alone closed only half of it: because endpoints are immutable,
a document naming the incumbent’s kind and id but different endpoints still read
as the same edge, so update overwrote the incumbent’s properties and silently
retained its old endpoints. The update is additionally issued with all five
identity components in the statement’s own WHERE, so the check cannot be raced
by a concurrent hard-delete-and-recreate; an update that consequently matches no
row is reported as the same per-row error rather than aborting the import.
Nodes were never affected: their probe is getNode(graphId, kind, id), which is
kind-scoped, so a cross-kind id collision simply reads as absent.
An update target that changed under the import
Section titled “An update target that changed under the import”onConflict: "update" is a read-then-write pair: the import probes the stored
row, validates the document’s validity window against that row’s valid_from,
and then writes. Every part of that verdict is restated in the UPDATE’s own
WHERE — for edges the five identity components above, and for both nodes
and edges the effective validity lower bound, whenever the window check actually
read it. A concurrent hard-delete-and-recreate between the probe and the write
therefore matches no row instead of landing a decision computed for a row that
is gone (which would have ignored a validFrom the document stated, or
persisted a validTo below the new row’s validFrom).
The bound is read — and so restated — when the document states a validFrom to
compare against it, or a lone validTo to check for an inverted window. A
document that states neither makes no claim about the row’s window, so its
properties update is not fenced on the bound and a concurrent recreate that only
moved the bound does not refuse it. This matches store.nodes.*.update exactly:
a write asserts what its decision read, and nothing more.
A write that matches no row is reported per row, so an import whose earlier rows are already written is not aborted for it:
const result = await importGraph(store, data, { onConflict: "update" });const raced = result.errors.filter( (entry) => entry.error.startsWith("INTERCHANGE_NODE_UPDATE_TARGET_CHANGED") || entry.error.startsWith("INTERCHANGE_EDGE_KIND_CONFLICT"),);INTERCHANGE_NODE_UPDATE_TARGET_CHANGED is the node-side prefix;
edges reuse INTERCHANGE_EDGE_KIND_CONFLICT, whose message now also names the
validity lower bound. Re-export the source and retry.
A node update refused this way leaves no partial trace, and neither does one
refused for a uniqueness conflict. The row write and the uniqueness transition
are one unit: the new keys are claimed first (the claim is what decides the
conflict), the row write follows, and the old keys are released only once it
lands — with the claims given back if it does not. Fulltext and embedding
sidecars are written only after the row update reports a match. So a row that
result.errors reports is a row the import did not change, even though the
transaction around it commits.
Unknown Property Handling
Section titled “Unknown Property Handling”When importing data that has properties not defined in your schema:
error - Reject the import (default, safest):
await importGraph(store, data, { onUnknownProperty: "error" });// Throws if data has { name: "Alice", unknownField: "value" }strip - Remove unknown properties silently:
await importGraph(store, data, { onUnknownProperty: "strip" });// { name: "Alice", unknownField: "value" } becomes { name: "Alice" }allow - Pass through to storage:
await importGraph(store, data, { onUnknownProperty: "allow" });// Behavior depends on your database and schema strictnessBackup and Restore
Section titled “Backup and Restore”Creating Backups
Section titled “Creating Backups”import { exportGraph } from "@nicia-ai/typegraph/interchange";import fs from "fs/promises";
async function createBackup(store: Store, backupDir: string) { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const filename = `backup-${timestamp}.json`;
const data = await exportGraph(store, { includeMeta: true, includeTemporal: true, });
await fs.writeFile( `${backupDir}/${filename}`, JSON.stringify(data, null, 2) );
return filename;}Restoring from Backup
Section titled “Restoring from Backup”import { importGraph, GraphDataSchema } from "@nicia-ai/typegraph/interchange";import fs from "fs/promises";
async function restoreBackup(store: Store, backupPath: string) { const json = await fs.readFile(backupPath, "utf-8"); const data = GraphDataSchema.parse(JSON.parse(json));
const result = await importGraph(store, data, { onConflict: "update", // or "error" for clean restore onUnknownProperty: "error", });
if (!result.success) { throw new Error(`Restore failed: ${result.errors.map(e => e.error).join(", ")}`); }
return result;}Migration Between Environments
Section titled “Migration Between Environments”Move data from development to staging, or staging to production:
import { createStore } from "@nicia-ai/typegraph";import { exportGraph, importGraph } from "@nicia-ai/typegraph/interchange";import { graph } from "./schema";
async function migrateData( sourceBackend: GraphBackend, targetBackend: GraphBackend,) { const sourceStore = createStore(graph, sourceBackend); const targetStore = createStore(graph, targetBackend);
// Export from source const data = await exportGraph(sourceStore);
// Import to target const result = await importGraph(targetStore, data, { onConflict: "error", // Ensure clean migration onUnknownProperty: "error", validateReferences: true, });
return result;}Building Custom Import Pipelines
Section titled “Building Custom Import Pipelines”For complex import scenarios, you can build pipelines using the Zod schemas:
import { GraphDataSchema, InterchangeNodeSchema, InterchangeEdgeSchema, type GraphData,} from "@nicia-ai/typegraph/interchange";
// Transform external data to interchange formatfunction transformExternalData(externalRecords: ExternalRecord[]): GraphData { const nodes = externalRecords.map((record) => ({ kind: "Document", id: record.externalId, properties: { title: record.name, content: record.body, source: { system: "external", id: record.externalId }, }, }));
// Validate each node const validatedNodes = nodes.map((node) => InterchangeNodeSchema.parse(node));
return { formatVersion: "2.0", exportedAt: new Date().toISOString(), source: { type: "external", description: "Imported from external CMS", }, nodes: validatedNodes, edges: [], };}Error Handling
Section titled “Error Handling”Import returns detailed error information for partial failures:
const result = await importGraph(store, data, { onConflict: "error" });
if (!result.success) { for (const error of result.errors) { console.error( `Failed to import ${error.entityType} ${error.kind}:${error.id}: ${error.error}` ); }
// Decide how to handle partial import if (result.nodes.created > 0 || result.edges.created > 0) { console.log("Partial import completed, some entities were created"); }}Serialized-connection refusals
Section titled “Serialized-connection refusals”Row-level failures are reported in result.errors, but one class of failure is
thrown instead: two long-lived interchange streams cannot share a single
serialized database connection, so whichever starts second is refused with a
typed ConfigurationError. The lease is exclusive — one stream of any kind per
connection — and every long-lived import claims it, so importGraph,
importGraphStream, trustedImportGraph, and trustedImportGraphStream can all
throw it, as can exportGraphStream when an import already holds the connection
— there, on the stream’s first pull, since the claim begins when the snapshot
transaction opens rather than when the iterable is constructed. The codes (INTERCHANGE_SHARED_SERIALIZED_BACKEND_SNAPSHOT,
INTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOT,
INTERCHANGE_SERIALIZED_IMPORT_IN_PROGRESS) and the details.requested /
details.heldBy pairing they carry are documented in
Interchange serialized-connection guard codes.
Which connections count as serialized is read off the driver, so a driver
TypeGraph cannot identify is left unmarked and a stream pair on it can still
wedge. createSqliteBackend and createPostgresBackend take a
serializedResource declaration for both directions of that gap —
{ mode: "shared", resource: client } marks a connection TypeGraph cannot see,
{ mode: "independent" } escapes a detection that is wrong for your topology.
The escape hatch lifts the shared-resource refusal between two distinct backends
only: one SQLite backend exporting into itself stays refused with
INTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOT, because that is one handle holding
the one snapshot transaction its own import needs. That surviving refusal is
SQLite-only, so on PostgreSQL a backend declared independent may export into
itself. See
Serialized connections.
Best Practices
Section titled “Best Practices”Validate Before Import
Section titled “Validate Before Import”Always validate external data before importing:
import { GraphDataSchema } from "@nicia-ai/typegraph/interchange";
const result = GraphDataSchema.safeParse(untrustedData);if (!result.success) { console.error("Invalid data:", result.error.format()); return;}
await importGraph(store, result.data, options);Use Transactions for Consistency
Section titled “Use Transactions for Consistency”Import operations use transactions when the backend supports them. When the Store carries a reconciled schema version, every import batch acquires and validates the same schema-write fence as collection writes; a stale managed Store fails before row DML. A raw Store remains outside that guarantee. On a raw Store without transaction support, consider smaller batch sizes to minimize partial-failure impact; a managed Store on that backend fails closed on its first write.
Test with onConflict: "error" First
Section titled “Test with onConflict: "error" First”When setting up a new import pipeline, use onConflict: "error" to catch
unexpected duplicates early:
// Development/testingawait importGraph(store, data, { onConflict: "error" });
// Production (after validation)await importGraph(store, data, { onConflict: "update" });Monitor Import Results
Section titled “Monitor Import Results”Log import statistics for observability:
const result = await importGraph(store, data, options);
logger.info("Import completed", { success: result.success, nodesCreated: result.nodes.created, nodesUpdated: result.nodes.updated, nodesSkipped: result.nodes.skipped, edgesCreated: result.edges.created, edgesUpdated: result.edges.updated, edgesSkipped: result.edges.skipped, identityCreated: result.identity.created, identitySkipped: result.identity.skipped, errorCount: result.errors.length,});Next Steps
Section titled “Next Steps”- Data Sync - Patterns for keeping external data in sync
- Schema Migrations - Managing schema changes over time
- Integration Patterns - Database setup and deployment