Graph Extensions
Graph extensions let your application declare new node and edge
kinds at runtime — durable across restarts, with semantic parity to
compile-time defineNode / defineEdge. The motivating use case:
agent-driven schema induction, where an LLM proposes a typed schema
from a corpus, an operator approves it, and the live graph immediately
ingests under the new schema with no code change or restart.
This guide covers the core verbs:
| Verb | Purpose |
|---|---|
defineGraphExtension |
Build a typed extension (pure value, no I/O) |
store.evolve(extension) |
Atomically commit a new schema version with the extension applied |
store.introspect() |
Snapshot the merged schema, persisted extension, version, and hash |
store.materializeIndexes() |
Run declared CREATE INDEX DDL against the live database |
store.deprecateKinds(...) / undeprecateKinds |
Soft-deprecate kinds for codegen / lint signaling |
store.removeKinds(...) |
Remove graph-extension-declared kinds from the active schema |
store.materializeRemovals() |
Delete rows queued by graph-extension-kind removal |
For the schema-management primitives that graph extensions ride on top of, see Schema Migrations and Evolving Schemas.
When to use graph extensions
Section titled “When to use graph extensions”Use them when the kind set is not known at code time:
- Agent / LLM proposes a new typed schema from observed data.
- Multi-tenant deployments where each tenant defines their own kinds.
- ETL pipelines that ingest sources with shifting structure.
- Plugins / extensions that contribute kinds at install time.
For everything else — kinds you can declare in TypeScript at deploy time
— use the compile-time DSL (defineNode, defineEdge, defineGraph).
The compile-time path is type-safe end-to-end; graph extensions trade
some of that type-safety for the ability to evolve without redeploying.
A complete example
Section titled “A complete example”import { z } from "zod";import { createStoreWithSchema, defineGraph, defineNode, defineGraphExtension,} from "@nicia-ai/typegraph";import { createLocalSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite/local";
// 1. Boot with a compile-time kind.const Document = defineNode("Document", { schema: z.object({ title: z.string(), body: z.string() }),});const baseGraph = defineGraph({ id: "research_corpus", nodes: { Document: { type: Document } }, edges: {},});
const { backend } = createLocalSqliteBackend();const [store] = await createStoreWithSchema(baseGraph, backend);
// 2. An agent proposes a new kind at runtime.const proposal = defineGraphExtension({ nodes: { Paper: { description: "An academic paper inferred from the corpus", properties: { title: { type: "string", minLength: 1 }, doi: { type: "string", minLength: 1 }, year: { type: "number", int: true, min: 1900, max: 2100 }, }, unique: [{ name: "paper_doi_unique", fields: ["doi"] }], }, }, indexes: [ { entity: "node", kind: "Paper", name: "paper_by_doi", fields: ["doi"], unique: true, }, ],});
// 3. Operator approves; commit atomically.const evolved = await store.evolve(proposal);
// 4. Use the dynamic-collection accessor (the type system does not// widen for extension kinds — see "Reaching extension kinds" below).const papers = evolved.getNodeCollection("Paper")!;await papers.create({ title: "Attention is all you need", doi: "10.5555/3295222.3295349", year: 2017,});A complete runnable version is in examples/16-graph-extensions.ts.
Instantiate a graph template
Section titled “Instantiate a graph template”For many tenant graphs with the same final shape, register a verified schema once and instantiate each target as schema version 1. The template document stays in the database; instantiation sends only its id, the target graph id, and a client-computed content hash.
const [source] = await createAdapterStoreWithSchema(baseGraph, adminBackend);const template = await registerGraphTemplate(adminBackend, { templateId: "research-corpus-v1", reconciled: source.reconciledSchema,});
const target = await instantiateGraph(adminBackend, { template, graphId: "tenant-123",});
// The returned target snapshot can create a request-scoped Store without// another schema read. `tenantGraph` is the same compile-time shape with id// "tenant-123".const store = createAdapterStore(tenantGraph, runtimeBackend, { reconciled: target.reconciled,});Instantiation is idempotent for the same template and target. A target already initialized with a different schema is refused. On PostgreSQL the clone statement takes the same graph advisory-lock key as schema commits; SQLite’s schema and marker writes are serialized by its writer lock.
Templates clone the source graph’s graph-local runtime-contribution activation
markers along with its schema row. Deployment-scoped physical attestations,
such as the shared fulltext table marker, already belong to the database and
are not duplicated per template target. A target can therefore be reopened with
createVerifiedStore or createVerifiedAdapterStore from a later serverless
isolate without another schema reconciliation or provisioning DDL step.
Registration and instantiation are DML-only. They assert the deployment-wide
base-schema marker and throw BaseSchemaMigrationError rather than attempting
DDL when adoption is missing, stale, or newer than the running library. Run the
normal privileged createStoreWithSchema boot or the published external
migration before handing runtime requests to a DML-only role.
Template eligibility follows the backend’s capabilities. Embedding declarations
are allowed when the backend is configured with vector: false, because that
backend has no graph-scoped vector storage to clone. A vector-enabled backend
continues to refuse embedding-bearing schema-only templates.
The graph extension
Section titled “The graph extension”defineGraphExtension accepts a structured value describing the new
kinds. The extension is JSON-serializable — that’s load-bearing for
durability (see Restart parity).
Document format versioning
Section titled “Document format versioning”Every document carries a version field (currently 1). The validator
stamps the version automatically when you call
defineGraphExtension, so consumer code never has to set it
explicitly. Stored documents from before this field existed are
treated as version: 1 (the legacy default).
The forward-compat policy:
- Additive minor changes (new optional property modifier, new
formatvalue, new top-level slice within the same major) ride forward without bumpingversion. The validator does not reject unknown top-level keys, and the persistence-side zod is.loose()on every nested object — an older runtime reading a newer extension silently ignores unknown fields and continues working. - Breaking changes bump
versionto a higher major. An older runtime reading a higher-version extension fails withGRAPH_EXTENSION_VERSION_UNSUPPORTEDand an actionable error pointing the operator at upgrading the library — there is no automatic downgrade path. The current major is exported asCURRENT_GRAPH_EXTENSION_VERSIONfor tooling that wants to pre-flight check. - Legacy extensions (committed before
versionexisted) and extensions that explicitly omitversionare interpreted asLEGACY_GRAPH_EXTENSION_VERSION, pinned permanently to1. This is deliberately distinct fromCURRENT: when a future v2 ships, legacy v1 extensions continue parsing as v1 (so the version-mismatch path can route them through migration) rather than being silently re-classified as v2 by a default-equals-current rule.
import { CURRENT_GRAPH_EXTENSION_VERSION, LEGACY_GRAPH_EXTENSION_VERSION,} from "@nicia-ai/typegraph";
console.log(CURRENT_GRAPH_EXTENSION_VERSION); // 1 (today; bumps with breaking changes)console.log(LEGACY_GRAPH_EXTENSION_VERSION); // 1 (always; the pre-versioning default)Property types (the v1 subset)
Section titled “Property types (the v1 subset)”The following types are supported. The set is deliberately small so that LLM-induced schemas can be audited at a glance and so the persistence layer never has to reconstruct opaque Zod refinements from JSON.
| Type | JSON shape |
|---|---|
string |
{ type: "string", minLength?, maxLength?, pattern?, format? } |
number |
{ type: "number", int?, min?, max? } |
boolean |
{ type: "boolean" } |
enum |
{ type: "enum", values: ["a", "b", ...] } |
array |
{ type: "array", items: <leaf or single-level object> } |
object |
{ type: "object", properties: { foo: <leaf>, ... } } (one nesting only) |
Supported string formats: "datetime", "uri", "email", "uuid",
"date". These route to the corresponding Zod factories
(z.iso.datetime(), z.url(), z.email(), z.uuid(), z.iso.date()).
Modifiers available on every property:
optional?: true— omits from the required set.description?: string— surfaces in tooling.searchable?: SearchableModifier— string only; routes through thesearchable()brand for fulltext indexing.embedding?: { dimensions: number }— array-of-number only; routes through theembedding()brand for vector search.
Unique constraints
Section titled “Unique constraints”Pass unique: [{ name, fields, ... }] per kind. The name is required
(used as the diffing identity key) and must be unique within the kind.
Supports scope, collation, and a restricted where clause limited
to isNull / isNotNull (the only operations round-trippable through
the persisted form).
Relational indexes
Section titled “Relational indexes”Pass indexes: [...] at the document top level to declare relational
indexes for graph-extension or compile-time host kinds:
const proposal = defineGraphExtension({ nodes: { Paper: { properties: { doi: { type: "string" }, title: { type: "string", searchable: { language: "english" } }, }, }, }, indexes: [ { entity: "node", kind: "Paper", name: "paper_by_doi", fields: ["doi"], unique: true, }, ],});Index names are unique across the merged graph. A graph-extension index that
reuses a compile-time index name, or a later extension that reuses an
earlier graph-extension index name for a different declaration, is rejected.
Edges follow the same shape as nodes and declare endpoints using kind names.
An array-valued to permits every combination of from and to kinds.
A map-valued to restricts targets by source kind:
const proposal = defineGraphExtension({ edges: { assignedTo: { from: ["Employee", "Student"], to: { Employee: ["Department"], Student: ["Course"], }, properties: {}, }, },});const evolved = await store.evolve(proposal);All endpoint kinds must resolve in the merged graph when evolve() runs.
Map keys must exactly cover from, and each target array must be nonempty.
The map is persisted and restored on restart, preserving the same runtime pair
validation as compile-time declarations.
Adding allowed pairs broadens an extension edge. Removing a pair tightens it, even if the overall source and target kind sets stay the same. Tightening currently requires the entire edge kind to be empty, not just the removed pair.
Ontology
Section titled “Ontology”Pass ontology: [{ metaEdge, from, to }, ...] to declare ontology
relations between kinds (subClassOf, partOf, etc.). The meta-edge name
must match a meta-edge known to the merged graph.
store.evolve(extension, options?)
Section titled “store.evolve(extension, options?)”const evolved = await store.evolve(extension);const evolved = await store.evolve(extension, { ref });const evolved = await store.evolve(extension, { eager: {} });evolve is the consumer-facing primitive that drives extension. It:
- Catches up to persisted state — folds any persisted extension and deprecation set into the local baseline so a stale store doesn’t trample another writer’s progress.
- Merges the new extension into the baseline graph. Re-declaring
an existing extension kind with the same shape is a no-op; with a
non-additive change against existing rows it throws
IncompatibleChangeError(codeINCOMPATIBLE_CHANGE). - Atomically commits a new schema version via
commitSchemaVersion(CAS on the active version). - Returns the resulting
Store<G>carrying the extended graph. The type parameterGdoes NOT widen — see Reaching extension kinds below.
Plan outside and apply inside a caller-owned transaction
Section titled “Plan outside and apply inside a caller-owned transaction”planEvolution(extension) prepares a named schema change before the caller
opens its write transaction. It returns an immutable "noop" or "change"
plan with graphId, baseline: { version, hash }, and
result: { version, hash }. Change plans expose an ordered requirements
array whose entries name new-kind additions, empty-kind checks, vector slots,
and identity work. A new-kind entry describes a graph delta; it does not
indicate that a removal is queued or direct callers to run
materializeRemovals(). The plan is opaque and bound to the loaded TypeGraph
module: it cannot be serialized, cloned, or reconstructed. It can be passed between
compatible Stores for the same graph that use the same loaded module; apply
still checks the active graph and fenced baseline version/hash. The
default { source: "database" } reloads the active schema. { source: "cached" } uses a previously loaded planning snapshot on the same Store; a
cached plan is not fresh database evidence. A stale baseline is refused during
apply, so retry by rolling back the whole caller transaction and replanning
outside it.
Use withEvolvedTransaction(nativeTx, plan, callback, { waitBudgetMs }) when
the schema change, TypeGraph writes, and application SQL must share the
caller’s commit. Enter this boundary before other TypeGraph callbacks on the
same native transaction. The callback receives an evolved transaction context,
including its reads, collections, and supported composition operations. It
does not receive a replacement root Store.
const cachedStore = store;const ref = { current: cachedStore };const plan = await cachedStore.planEvolution(proposal);const writerStore = cachedStore.withBackend(writerBackend);
const provisional = await db.transaction(async (nativeTx) => { const outcome = await writerStore.withEvolvedTransaction( nativeTx, plan, async (tx) => { const person = await tx.nodes.Person.create({ name: "Ada" }); return person.id; }, plan.status === "change" ? { waitBudgetMs: 5000 } : undefined, );
await nativeTx.insert(applicationEvents).values({ personId: outcome.result, schemaVersion: outcome.receipt.schema.version, }); return outcome;});
const refreshed = await cachedStore.refreshSchema({ minVersion: provisional.receipt.schema.version, ref,});The callback and receipt finish before the outer SQL transaction commits.
The receipt’s schema version/hash and recorded anchor are provisional until
that commit succeeds. A callback failure must reject the outer transaction;
catching it and committing does not prove rollback. Callback contexts and
queries built from them expire when the callback returns, including after a
failure. Use refreshSchema() only after awaiting a successful outer commit.
When the cached Store already matches minVersion, refresh returns it
without a read; that shortcut does not check for a newer database version.
Otherwise refresh reads the active schema, accepts a newer committed version,
and refuses a missing or older one. It applies no extension or storage
provisioning.
The adopted apply path supports metadata-only changes, required-empty checks,
and transactional identity and vector provisioning. Configure the adapter with
schemaProvisioning: "transactional" on a privileged connection when the plan
names new vector slots or identity work. The adapter’s default DML-only policy
refuses those requirements before the schema fence, DDL, callback, or mutation.
The privileged path rechecks storage on the caller’s fenced session, provisions
the required relations and vector contribution markers there, and rolls them
back with the outer transaction. Missing bootstrap tables still refuse; run
the normal bootstrap before serving adopted evolution requests.
The apply path uses a bounded schema fence, baseline validation, and a
version CAS; it can issue multiple statements. waitBudgetMs bounds fence
acquisition for change plans; no-op plans refuse an explicit waitBudgetMs.
On timeout, roll back and retry the complete native transaction.
Do not pass ref or eager-index options to withEvolvedTransaction; they
cannot be honored before the outer commit and are refused.
Generic and concurrent eager indexes remain explicit maintenance after commit:
call materializeIndexes() on the refreshed Store when they are needed.
When a wiring pass produces a no-op, ordinary recorded adoption is sufficient and never takes the exclusive evolution fence. Reconcile only if the Store is behind the named snapshot; matching versions make this refresh a cached read:
if (plan.status === "noop") { const current = await store.refreshSchema({ minVersion: plan.baseline.version }); await db.transaction((nativeTx) => current.withRecordedTransaction(nativeTx, async (tx) => { await tx.nodes.Person.create({ name: "Ada" }); }), );}The ref pattern
Section titled “The ref pattern”Store<G> is immutable by construction — evolve() returns the Store for the
resulting schema, using a fresh instance when the schema advances. Long-lived
consumer code that holds the Store in a singleton needs a way to re-bind it
before the operation completes. Pass options.ref: { current: store } (a
StoreRef<Store<G>>):
const ref: StoreRef<Store<G>> = { current: store };const evolved = await ref.current.evolve(extension, { ref });
// `ref.current === evolved`; use either reference from here on.const papers = ref.current.getNodeCollectionOrThrow("Paper");await papers.create({ title: "...", doi: "...", year: 2024 });Capture ref.current once at request entry only when that request will not
change the schema. If it does call evolve(), switch to the returned Store or
dereference ref.current again after the call. StoreRef<T> is structurally
just { current: T }; the library doesn’t provide a factory because the
consumer composes the handle themselves (it could be a Vue ref, MobX
observable, Zustand atom, etc.).
The ref covers schema changes made by calls that receive it. If another process
or isolate can advance the schema, probe the committed version before reusing a
cached Store and perform a verified open when it changes. See
Per-request connections: cache the verified Store
for the complete getCommittedSchemaVersion() recipe.
Eager materialization
Section titled “Eager materialization”Pass eager: {} to materialize indexes immediately after the schema
commit:
const evolved = await store.evolve(extension, { eager: {} });Or pass options for finer control:
// Restrict to the extension kind whose index was declared in the// proposal above.const evolved = await store.evolve(extension, { eager: { kinds: ["Paper"], stopOnError: true },});Omit eager to skip materialization and run materializeIndexes()
later.
Per-index failures throw EagerMaterializationError AFTER the new
Store is constructed and ref.current is updated, so the caller can
recover via the ref handle. The schema commit is not rolled back
if materialization fails — eager is convenience, not a transaction.
const ref = { current: store };try { await store.evolve(extension, { ref, eager: {} });} catch (error) { if (error instanceof EagerMaterializationError) { // Schema is committed; ref.current is the new store. log.warn( { failed: error.failedIndexNames }, "indexes did not materialize; will retry", ); await ref.current.materializeIndexes(); } else { throw error; }}Reaching extension kinds from the type system
Section titled “Reaching extension kinds from the type system”TypeScript can’t see kinds that don’t exist at compile time. The
Store<G> returned by evolve() keeps the same generic parameter as
the original — evolved.nodes.Paper would not type-check.
The escape hatch is store.getNodeCollection(kind) and
store.getEdgeCollection(kind), which return a typed
DynamicNodeCollection / DynamicEdgeCollection:
const papers = evolved.getNodeCollection("Paper");if (papers === undefined) { throw new Error("Paper kind not registered on this store");}await papers.create({ title: "...", doi: "...", year: 2024 });const all = await papers.find({});The throwing variants getNodeCollectionOrThrow(kind) /
getEdgeCollectionOrThrow(kind) are the right call when the caller
already knows the kind has been evolved onto the store — they raise
KindNotFoundError with the offending kindName, entity, and host
graphId instead of returning undefined, so a typo fails loudly at
the call site rather than crashing later on papers!.create(...).
DynamicNodeCollection exposes the same CRUD surface as
store.nodes.X — create, getById, find, update, delete,
etc. — but with DynamicNode element types since the specific Zod schema isn’t
visible to TypeScript at the call site.
In TypeScript, nodes returned by a dynamic collection carry the nominal
DynamicNode<K> type, preserving the requested kind literal. That proof lets
runtime kinds participate directly in Operational Identity without weakening
compile-time references to arbitrary string kinds:
const paper = await evolved .getNodeCollectionOrThrow("Paper") .create({ title: "Runtime schemas" });
await evolved.identity.assertSame(document, paper);Identity reads can consequently return IdentityNodeReference<G> values for
either compile-time or runtime kinds. See Operational Identity.
For consumers that need the live Zod schema itself — MCP tool wrappers
that validate inputs before forwarding to collection.create, or
agent prompts that want richer JSON Schema than introspect()
exposes — store.getNodePropsSchema(kind) /
getNodePropsSchemaOrThrow(kind) (and the edge counterparts) return
the exact z.ZodObject the store uses internally. Identity holds:
evolved.getNodePropsSchema("Paper") is the same instance the store
parses against on papers.create(...).
import { z } from "zod";
const schema = evolved.getNodePropsSchemaOrThrow("Paper");const parsed = schema.parse(input); // same Zod issues as papers.create surfacesconst jsonSchema = z.toJSONSchema(schema); // for MCP tool descriptionsThese accessors return only the props validator. Operation-level
checks — uniqueness, endpoint resolution, temporal validity, backend
constraints — still run only through collection.create / update.
See Dynamic Props Schema Access
for the full reference.
For codegen consumers, the kind set is reachable by iterating the
registry’s nodeKinds and edgeKinds maps:
const allNodeKinds = [...store.registry.nodeKinds.keys()];const allEdgeKinds = [...store.registry.edgeKinds.keys()];const personType = store.registry.getNodeType("Person"); // NodeType | undefinedKindRegistry also exposes hasNodeType(name) / hasEdgeType(name)
for existence checks.
Querying extension kinds
Section titled “Querying extension kinds”store.query() requires every from / traverse / to kind to be a
compile-time literal in Store<G>. The string-keyed siblings
fromDynamic / traverseDynamic / optionalTraverseDynamic /
toDynamic admit kinds added via evolve() so an MCP server (or any
caller working from kind names in a string variable) can build typed
multi-hop traversals without as any:
const rows = await store.query() .fromDynamic("Paper", "p") .traverseDynamic("authoredBy", "a") .toDynamic("Author", "u") .whereNode("p", (p) => p.field("year").number().gte(2020)) .select((ctx) => ({ paper: ctx.p, author: ctx.u, edge: ctx.a })) .execute();Each method runtime-validates against the registry: a typo throws
KindNotFoundError, and a toDynamic target that isn’t a valid
endpoint for the current edge / direction throws EndpointError.
Compile-time from / traverse / to are unchanged.
When the extension document is available in typed code, mint Store-bound runtime-kind evidence from the exact persisted definition. The token narrows collections and query aliases without asking callers to restate the schema in Zod:
const tagKind = store.runtimeNodeKind("Tag", extension.nodes.Tag);const taggedWithKind = store.runtimeEdgeKind( "taggedWith", extension.edges.taggedWith,);
const tags = store.getNodeCollectionOrThrow(tagKind);const rows = await store.query() .fromDynamic(tagKind, "tag") // ctx.tag has the declared Tag fields .traverseDynamic(taggedWithKind, "edge") // ctx.edge is narrowed too .toDynamic("Document", "document") .select((ctx) => ({ label: ctx.tag.label, weight: ctx.edge.weight })) .execute();Definitions, rather than hand-authored Zod schemas, are the type evidence:
TypeGraph compares the complete graph-extension declaration that TypeScript
infers against the definition persisted for that kind. This keeps refinements
such as enums, optionality, arrays, and numeric constraints on one authoritative
surface. Tokens are bound to the issuing Store and active schema hash; use a
fresh token after reopening or evolving a Store. Token lookups intentionally
use the throwing getNodeCollectionOrThrow / getEdgeCollectionOrThrow
variants because valid Store-issued evidence already proves that the kind
exists.
Predicate accessors on dynamic aliases use a .field(name)
discriminator:
BaseFieldAccessormethods (eq,isNull,in,notIn) are available directly onfield("name").- Type-specific predicates sit behind a discriminator method that
asserts the field’s type —
.string()/.number()/.date()/.array()/.object()/.embedding(). Each validates against the registered Zod schema and throwsTypeErroron mismatch, sofield("year").string()against a number field is caught at query-build time, not as a silent “method is undefined” later. .field("missing")throws when the property isn’t on the schema.
Mixed typed and dynamic aliases
Section titled “Mixed typed and dynamic aliases”Typed and dynamic aliases interleave freely in one query. The
predicate accessor is resolved per alias — a typed alias keeps its
narrow StringFieldAccessor etc., while a dynamic alias gets .field():
const rows = await store.query() .from("Document", "d") // typed compile-time kind .traverseDynamic("taggedWith", "e") // runtime edge .toDynamic("Tag", "n") // runtime target .whereNode("d", (d) => d.title.eq("the doc")) // typed: direct .whereNode("n", (n) => n.field("label").string().eq("research")) // dynamic: discriminator .select((ctx) => ({ doc: ctx.d, tag: ctx.n })) .execute();A typed traverse("typedEdge", "e") followed by .toDynamic(target, "n")
keeps the edge alias e typed — e.role.eq(...) works directly, no
discriminator needed. Only the dynamic-declared aliases use .field().
Optional dynamic traversal
Section titled “Optional dynamic traversal”optionalTraverseDynamic is the LEFT-JOIN sibling — papers without
authors still surface, with the edge and target aliases as undefined:
const rows = await store.query() .fromDynamic("Paper", "p") .optionalTraverseDynamic("authoredBy", "a") .toDynamic("Author", "u") .select((ctx) => ({ paper: ctx.p, author: ctx.u, edge: ctx.a })) .execute();// row.author and row.edge are undefined for papers without an authoredBy edge.Search facade
Section titled “Search facade”The store.search facade — fulltext, vector, hybrid, and
rebuildFulltext — accepts any registered kind, compile-time or
runtime, with no type cast. The hit’s node type narrows to the
concrete typed node only when the kind literal is statically known
in Store<G>; extension kinds widen to the base Node. Misspelled
kind names throw KindNotFoundError at the call site instead of
returning empty results.
// Compile-time kind: hit.node.title is narrowed.const compileTimeHits = await store.search.fulltext("Document", { query: "climate", limit: 10,});
// Extension kind: same call shape, no cast. hit.node is the base// `Node` shape since "Paper" isn't in the static `G`.const runtimeHits = await store.search.fulltext("Paper", { query: "attention transformer", limit: 10,});store.introspect()
Section titled “store.introspect()”introspect() returns a frozen snapshot of the merged schema and the
durable-state metadata the store has loaded so far. Its shape:
| Field | Type | Notes |
|---|---|---|
graphId |
string |
The graph’s stable id. |
annotations |
GraphAnnotations | undefined |
Merged graph-scoped metadata from defineGraph and runtime extensions. |
kinds |
readonly KindIntrospection[] |
Merged node kinds with origin: "compile-time" | "runtime", description, annotations, etc. |
edges |
readonly EdgeIntrospection[] |
Merged edge kinds with the same origin discriminator and endpoint information. |
ontology |
readonly OntologyIntrospection[] |
Ontology relations declared on either tier. |
deprecatedKinds |
ReadonlySet<string> |
Kinds flagged via deprecateKinds(...). Informational, not a gate. |
extension |
GraphExtension | undefined |
The persisted graph-extension document, or undefined when no extensions have been committed. |
schemaVersion |
number | undefined |
Active schema version on the backend. undefined until the first commit. |
schemaHash |
string | undefined |
Hash of the active schema document. undefined under the same condition. |
const intro = store.introspect();console.log(intro.schemaVersion); // e.g. 2console.log(intro.annotations?.displayName);console.log(intro.extension?.nodes?.Paper); // ExtensionNodeDef or undefinedconsole.log([...intro.deprecatedKinds]); // ["LegacyDocument"]The extension field round-trips: passing it back through
defineGraphExtension(intro.extension!) and evolve() against an
empty graph reconstructs the same extension kinds.
For schema tooling that has an extension document but no Store, call
introspectGraphExtension(extension). It compiles the extension through the
same TypeGraph compiler used by evolve() and returns kinds and edges
with JSON Schema properties, descriptions, annotations, and endpoint names.
The result describes only the supplied document; it has no graph ID, committed
schema version, or schema hash.
import { introspectGraphExtension } from "@nicia-ai/typegraph";
const declaration = introspectGraphExtension(extension);console.log(declaration.kinds[0]?.properties);Graph extensions may also carry graph-scoped annotations:
const extension = defineGraphExtension({ annotations: { displayName: "Customer knowledge", capabilities: { semanticSearch: true }, },});Annotation keys are shallow-merged. A later extension replaces the complete value of each key it supplies; it does not recursively merge nested objects.
Population statistics and stored-data validation
Section titled “Population statistics and stored-data validation”await store.describe() pairs the merged schema introspection with current
population statistics. It returns node and edge counts for every declared kind
plus present, explicit-null, and non-null counts (and non-null coverage) for
each directly addressable declared property:
const description = await store.describe();const people = description.statistics.nodes.find( (entry) => entry.kind === "Person",);
console.log(people?.count);console.log( people?.properties.find((property) => property.path === "/email")?.coverage,);The schema coordinate includes the active schema version and hash when present
and a schemaFence. TypeGraph reads that coordinate before and after the
bounded, sequential SQL aggregate statements and refuses the result if it
changed. Node and edge properties are queried separately, and wide schemas are
split into fixed-width path batches. The database, rather than TypeGraph’s
JavaScript process, computes all counts. Coverage follows ordinary nested JSON
Schema properties; TypeGraph intentionally does not invent population
semantics through $ref, unions, intersections, arrays, or conditionals.
validateStore() remains authoritative for those schemas. Concurrent writes
can affect different describe() path batches differently; the schema fence
detects schema changes, not data changes.
Use validateStore() to find rows that no longer satisfy a kind’s current
declared Zod schema, for example after tightening a rule around existing data:
let cursor: string | undefined;do { const page = await store.validateStore({ entity: "node", kind: "Person", pageSize: 250, ...(cursor === undefined ? {} : { cursor }), }); for (const failure of page.violations) { console.log(failure.id, failure.path, failure.reason); } cursor = page.nextCursor;} while (cursor !== undefined);Undeclared properties are healthy semi-structured state and are never reported as violations, including when the authored Zod object is strict. Each failure names the record id, JSON-pointer path, top-level property when applicable, Zod issue code, and reason.
pageSize is the number of records scanned, not a cap on violations: one record
can contribute several Zod issues. Each request performs a bounded SQL keyset
scan (LIMIT pageSize + 1) and reports scannedCount; it never materializes or
rescans the complete kind just to continue. Cursors bind the entity, kind,
schema fence, and last scanned id. A schema change throws
StoreAnalysisCursorStaleError. Data pages are deliberately live rather than a
claimed cross-request snapshot, so concurrent inserts, updates, and deletes can
affect later pages. Each page still reads the schema coordinate before and after
its data statement and refuses a concurrent schema flip.
Both analysis methods are current-only. They are absent from StoreView;
recorded/as-of population analysis is deferred until it can be backed by an
equally explicit temporal contract. Root-store calls use sequential SQL
statements plus schema bracketing, so they also work on non-interactive
transactional adapters.
Transaction callbacks expose the same methods through their pinned session.
Choose repeatable_read or serializable when every describe() aggregate or
every validateStore() page must observe one database snapshot, and consume
all validation pages before the callback returns:
await store.transaction( async (tx) => { const description = await tx.describe(); let cursor: string | undefined; do { const page = await tx.validateStore({ entity: "node", kind: "Person", ...(cursor === undefined ? {} : { cursor }), }); cursor = page.nextCursor; } while (cursor !== undefined); return description; }, { isolationLevel: "repeatable_read", accessMode: "read_only" },);Calling root store.describe() from inside a transaction callback still does
not join that transaction; use tx.describe() or tx.validateStore() for the
bound-session behavior.
store.materializeIndexes(options?)
Section titled “store.materializeIndexes(options?)”const result = await store.materializeIndexes();// Restrict to specific compile-time or extension kinds.const result = await store.materializeIndexes({ kinds: ["Paper"] });const result = await store.materializeIndexes({ stopOnError: true });materializeIndexes runs CREATE INDEX DDL for the indexes declared
on the merged graph and tracks per-deployment status in
typegraph_index_materializations. It’s a separate verb from
evolve() because:
- DDL is per-database, not per-graph (two replicas of the same
schema_docare still two databases — DDL has to run on each). - Postgres uses
CREATE INDEX CONCURRENTLYso live tables never take anAccessExclusiveLock. CIC cannot run inside a transaction, which is whymaterializeIndexesruns at the top-level backend, never insidetransaction(). - Best-effort by default: per-index failures land in the result with
the captured
Errorand the loop continues. PassstopOnError: trueto halt on the first failure.
The returned MaterializeIndexesResult has one entry per declared
index with status: "created" | "alreadyMaterialized" | "failed" | "skipped".
The skipped status surfaces when the backend recognizes the
declaration but has no separate ANN index to build for it — e.g.
sqlite-vec (KNN lives in the vec0 virtual table), SQLite without a
vector engine, or embedding(dims, { indexType: "none" }) opting out
of automatic materialization.
Graph-extension-declared relational indexes use the same declaration shape as
compile-time defineNodeIndex / defineEdgeIndex, but in a
JSON-serializable form. They are persisted in schema_doc.extension,
re-derived on restart, and surface in store.graph.indexes with
origin: "runtime".
Vector indexes
Section titled “Vector indexes”Vector indexes are auto-derived from embedding() brands on both
compile-time and extension node kinds. Every top-level node field
declared with embedding(dims, opts?) produces one
VectorIndexDeclaration that flows through materializeIndexes()
like any relational index. No extra wiring required.
const Document = defineNode("Document", { schema: z.object({ title: z.string(), // Auto-derives a cosine HNSW vector index with pgvector // defaults (m=16, ef_construction=64). embedding: embedding(384), }),});
// Customize the auto-derived index by passing options at the brand.const Image = defineNode("Image", { schema: z.object({ embedding: embedding(512, { metric: "l2", m: 32, efConstruction: 100 }), }),});
// Opt out of automatic materialization while keeping the embedding.const Manual = defineNode("Manual", { schema: z.object({ embedding: embedding(384, { indexType: "none" }), }),});Embeddings live in per-(graphId, nodeKind, fieldPath) typed tables
named tg_vec_<graphId>_<kind>_<field> (each carrying the field’s
fixed dimension) — there is no single shared embeddings table. The
privileged migrator provisions each table plus a durable marker: at boot
via createStoreWithSchema, and for a field a runtime evolve()
introduces, by that evolve() call. The runtime hot path then asserts
the marker (never DDL), so a least-privilege role can read/write
embeddings.
On materializeIndexes():
- Postgres with pgvector: emits
CREATE INDEX ... USING hnsw ...(orivfflat) on the field’s per-(graphId, kind, field)vector table and reportscreated. - SQLite with
sqlite-vec: KNN lives in thevec0virtual table, so there’s no separate ANN index to build; declarations reportskipped(with a reason), notfailed. - libSQL / Turso: the DiskANN index is created via the strategy’s own
DDL (
libsql_vector_idx+vector_top_k). - SQLite without a vector engine: declarations report
skippedwith a reason indicating the backend lacks vector support.
The vector declaration’s identity key within a single graph is
(kind, fieldPath) — v1 allows at most one vector index per
(kind, field) pair. The auto-derived deterministic declaration
name is tg_vec_{kind}_{field}_{metric} — clean and scannable for
inspection in pg_indexes and result entries. Changing the metric
requires a different declaration name and explicit
re-materialization.
Cross-graph disambiguation lives at the materialization boundary,
not in the declaration name. Vector status rows in
typegraph_index_materializations are keyed on the compound
{graphId}::{declaration.name} for both auto-derived and explicit
VectorIndexDeclaration entries — so two graphs reusing the same
declaration name (whether auto-derived from the same kind/field or
constructed explicitly via defineGraph({ indexes: [...] })) don’t
collide in the status table. Each graph’s materializeIndexes()
call creates its own physical pgvector index on that graph’s
per-(graphId, kind, field) vector table and records its own status row.
Fulltext indexes (out of scope for v1)
Section titled “Fulltext indexes (out of scope for v1)”Fulltext indexes are NOT in the unified declaration channel for v1.
The fulltext table’s canonical index (Postgres GIN on tsv, SQLite
FTS5 virtual table) is created with the table itself by
bootstrapTables per the active FulltextStrategy. Per-kind fulltext
indexes are an “advanced strategy” surface that doesn’t fit the
relational-style declaration model and is reserved for future work.
Caveats (Postgres)
Section titled “Caveats (Postgres)”IF NOT EXISTSdoes not validate shape — only that something with that name exists. Drift detection uses TypeGraph’s recorded signature, not PG metadata. Signature mismatch surfaces asfailedwith adifferent signaturemessage.- Failed
CONCURRENTLYbuilds leave invalid indexes (pg_index.indisvalid = false). v1 surfaces this as afailedresult; the operator drops the invalid index manually before retry.
store.deprecateKinds(...) / undeprecateKinds(...)
Section titled “store.deprecateKinds(...) / undeprecateKinds(...)”await store.deprecateKinds(["LegacyDocument"]);console.log([...store.introspect().deprecatedKinds]); // ["LegacyDocument"]
await store.undeprecateKinds(["LegacyDocument"]);Soft-deprecation surfaces in
store.introspect().deprecatedKinds: ReadonlySet<string> for
introspection (codegen, UI tooling, lints) but does not gate reads,
writes, or queries. Bumps the schema version like any other change;
idempotent — re-deprecating an already-deprecated kind is a no-op.
Use cases:
- Codegen routes around deprecated kinds when generating new client code.
- Lint rules flag new code that touches deprecated kinds.
- UI tooling hides deprecated kinds from picker menus.
store.removeKinds(...) / materializeRemovals()
Section titled “store.removeKinds(...) / materializeRemovals()”removeKinds() removes graph-extension-declared kinds from the active schema.
It is intentionally two-phase:
- Schema commit.
removeKinds(names)rewrites the persisted graph extension without the named graph-extension kinds, cascades extension edges and ontology relations that can no longer resolve, and commits a new schema version with CAS. - Data cleanup.
materializeRemovals()deletes rows for removed node and edge kinds on the current deployment.
const withoutPaper = await evolved.removeKinds(["Paper"]);await withoutPaper.materializeRemovals();Pass { eager: {} } to run cleanup inline after the schema commit:
const withoutPaper = await evolved.removeKinds(["Paper"], { eager: {} });Removing an embedding field from a surviving kind orphans its
per-(graphId, kind, field) tg_vec_* table; materializeRemovals()
reclaims it and reports the count in
MaterializeRemovalsResult.reclaimedVectorFields.
For source-dependent edges, removing a node kind removes its source entry and
any target references to it. A source entry whose targets are exhausted is also
removed. The edge kind survives while another valid pair remains; it is cascaded
only when no pairs remain. For example, removing Course from the assignedTo
extension above preserves Employee → Department.
Removal only applies to graph-extension-declared kinds. Removing a compile-time
kind throws RemoveCompileTimeKindError; deploy new TypeScript code
for compile-time schema removal. Removing a graph-extension kind that is still
referenced by a compile-time edge or ontology relation throws
KindHasReferentsError, because TypeGraph cannot rewrite your
compiled graph for you.
Restart parity (the load-bearing invariant)
Section titled “Restart parity (the load-bearing invariant)”The graph extension is the durable source of truth.
Every call to evolve() persists the merged document into
schema_doc.extension. On startup, createStoreWithSchema()
reads it back, runs the same compiler, and reconstructs identical
Zod-bearing GraphDef. Net: an extension kind defined via evolve() is
indistinguishable from a compile-time kind after restart.
Verify this in your own tests:
const [store] = await createStoreWithSchema(baseGraph, backend);const evolved = await store.evolve(proposal);await evolved.getNodeCollection("Paper")!.create({ title: "...", doi: "...", year: 2024 });
// Different process / different deployment / fresh store...const [restored] = await createStoreWithSchema(baseGraph, backend);expect(restored.registry.hasNodeType("Paper")).toBe(true);const all = await restored.getNodeCollection("Paper")!.find({});expect(all).toHaveLength(1);Multi-process safety
Section titled “Multi-process safety”Concurrent writers compete on the commitSchemaVersion CAS. One wins;
the loser sees one of two errors with very different recovery semantics:
StaleVersionError— the local view of the active version is out of date. Routine race signal: refetch and retry.SchemaContentConflictError— a different writer wrote a row at the same version with a different content hash. NOT a routine race. Two writers tried to commit semantically different schemas at the same version, which means one of them is operating on an inconsistent view of the world. Surface to the operator; do not blindly retry.
Retry recipe (only catches StaleVersionError):
async function evolveWithRetry<G extends GraphDef>( ref: StoreRef<Store<G>>, extension: GraphExtension, attempts = 3,): Promise<Store<G>> { for (let attempt = 0; attempt < attempts; attempt++) { try { return await ref.current.evolve(extension, { ref }); } catch (error) { if (error instanceof StaleVersionError) { // Refetch happens implicitly inside evolve()'s next call — // catch-up auto-merges the persisted state into the local // baseline, so the next attempt diffs against fresh state. continue; } // SchemaContentConflictError, GraphExtensionValidationError, // EagerMaterializationError, etc. all surface to the caller — // they require operator intervention or different handling, not // blind retry. throw error; } } throw new Error(`Failed to evolve after ${attempts} attempts`);}The internal #catchUpToStored step inside evolve() (and
deprecateKinds, materializeIndexes) folds the persisted graph-extension
document and deprecation set into the local baseline before computing
the next state, so a stale store applying an extension on top of an
out-of-date baseline doesn’t trample another writer’s progress.
Trust boundary
Section titled “Trust boundary”When the graph extension originates from an untrusted source — an LLM completion, user input, an external API — treat it as untrusted data. Specifically:
- Validation runs at the boundary.
defineGraphExtension(doc)rejects any input that doesn’t match the v1 subset (GraphExtensionValidationErrorwith per-issue paths). Don’t skip this step. If you want Result-style handling for untrusted JSON, callvalidateGraphExtension(raw, { strict: true })and surface the structured issues before callingevolve(). - Property types are deliberately small. The supported set
excludes things like
bigint,Date, custom Zod refinements, and arbitrary functions. An LLM cannot inject executable code by proposing an extension document. - Operator approval is your gate. The library doesn’t enforce
human-in-the-loop — your application does. Show the diff to a human
before calling
evolve(). - Persisted documents are part of your data. They’re stored in
schema_docalong with every other schema artifact; back them up, audit them, version-control them.
Out of scope for v1
Section titled “Out of scope for v1”- Fulltext index unification. Vector indexes flow through the
unified channel (auto-derived from
embedding()brands). Fulltext is still per-strategy: the GIN / FTS5 index is created with the fulltext table atbootstrapTablestime. Per-kind fulltext indexes are reserved for future work. - Multiple vector indexes per (kind, field). v1 allows at most one. To use a different metric for the same field, use a different field name or wait for v2.
- Hard-blocking reads/writes on deprecated kinds. Deprecation is informational. If you want strict enforcement, wrap collection access yourself.
- Auto drop+recreate on signature drift.
materializeIndexessurfaces drift as afailedresult; manual remediation is required to avoid risky lock semantics.
See also
Section titled “See also”- Schema Migrations — the lower-level primitives
evolve()rides on. - Evolving Schemas — recipes for compile-time schema changes.
- Errors —
EagerMaterializationError,GraphExtensionValidationError,StaleVersionError,SchemaContentConflictError.