Skip to content

Evolving Schemas in Production

Your graph schema will change as your application grows. This guide covers how to make those changes safely — from adding a field to renaming a node type.

For API reference, see Schema Migrations. For evolving the kind set itself at runtime (agent-induced kinds, plugin-supplied kinds, multi-tenant kind sets), see Graph Extensions.

When you call createStoreWithSchema(), TypeGraph:

  1. Serializes your current graph definition
  2. Compares it against the stored schema (by hash, then by diff)
  3. Safe changes — auto-migrates and bumps the version
  4. Breaking changes — throws MigrationError (or returns status: "breaking")

The key insight: TypeGraph manages schema metadata, not data migration. When you add an optional field, TypeGraph records that the schema now includes it. It does not alter existing rows — Zod defaults handle that at read time.

These changes are backwards compatible and auto-migrate without intervention:

  • Adding new node types
  • Adding new edge types
  • Adding optional properties (with defaults)
  • Adding ontology relations
  • Changing per-kind annotations (UI hints, audit policy, etc.)
// Version 1
const Person = defineNode("Person", {
schema: z.object({
name: z.string(),
}),
});
// Version 2 — safe, auto-migrates
const Person = defineNode("Person", {
schema: z.object({
name: z.string(),
email: z.string().optional(),
}),
});

On startup, createStoreWithSchema() returns status: "migrated". Existing Person nodes return email: undefined — no data transformation needed.

// Version 2 — add Company and worksAt in one deploy
const Company = defineNode("Company", {
schema: z.object({ name: z.string() }),
});
const worksAt = defineEdge("worksAt", {
schema: z.object({ role: z.string() }),
});
const graph = defineGraph({
id: "my_app",
nodes: {
Person: { type: Person },
Company: { type: Company },
},
edges: {
worksAt: { type: worksAt, from: [Person], to: [Company] },
},
});

This is a single safe migration. New node and edge types don’t affect existing data.

The annotations field on defineNode and defineEdge is part of the canonical schema, so any change bumps the schema version. Changes are classified as safe — no data migration needed, only the schema document is updated.

// Version 1
const Incident = defineNode("Incident", {
schema: z.object({ title: z.string() }),
annotations: {
ui: { titleField: "title", icon: "alert-triangle" },
},
});
// Version 2 — swap the icon, add audit policy
const Incident = defineNode("Incident", {
schema: z.object({ title: z.string() }),
annotations: {
ui: { titleField: "title", icon: "circle-alert" },
audit: { pii: false, retentionDays: 365 },
},
});

getSchemaChanges() reports each annotations-only change per kind:

import { getSchemaChanges } from "@nicia-ai/typegraph/schema";
const diff = await getSchemaChanges(backend, graph);
for (const change of diff?.nodes ?? []) {
if (change.details.includes("Annotations")) {
console.log(`${change.kind}: annotations changed (${change.severity})`);
// → "Incident: annotations changed (safe)"
}
}

The hash is computed with stable sorted-key order at every depth, so re-formatting the annotations object — or swapping sibling key order — does not bump the version. Only structural or value changes do.

A few things worth knowing:

  • Graphs that never set annotations produce identical canonical-form hashes to graphs from before this field existed. Adoption requires no migration.
  • The canonical form omits empty / default annotations, so absent, explicit undefined, and explicit {} all hash identically — no migration is triggered just by writing annotations: {}.
  • Annotations values must be JSON-serializable (bigint, function, Date, and other class instances are rejected at definition time).

See the schemas-stores reference for the full annotations contract.

These require explicit handling:

  • Removing node or edge types
  • Removing properties
  • Adding required properties (no default)
  • Renaming types or properties

TypeGraph will throw MigrationError by default. You have two options: fix the schema to be backwards compatible, or use the expand-contract pattern.

For breaking changes, use a multi-deploy strategy. This is the same pattern used in relational database migrations — deploy in phases so there’s never a moment where running code is incompatible with the schema.

Rename name to fullName on Person in three deploys:

const Person = defineNode("Person", {
schema: z.object({
name: z.string(),
fullName: z.string().optional(), // New property, optional for now
}),
});

Safe migration. Then backfill existing data:

const [store] = await createStoreWithSchema(graph, backend);
const people = await store.query(Person).execute();
for (const person of people) {
if (!person.properties.fullName) {
await store.nodes.Person.update(person.id, {
fullName: person.properties.name,
});
}
}

Deploy 2 — Switch: use the new property everywhere

Section titled “Deploy 2 — Switch: use the new property everywhere”

Update all application code to read/write fullName instead of name. Both properties still exist, so this deploy is safe.

Deploy 3 — Contract: remove the old property

Section titled “Deploy 3 — Contract: remove the old property”
const Person = defineNode("Person", {
schema: z.object({
fullName: z.string(),
}),
});

This is a breaking change (removing name). Use migrateSchema() to force it:

import { getSchemaChanges, migrateSchema } from "@nicia-ai/typegraph/schema";
const [store, result] = await createStoreWithSchema(graph, backend, {
throwOnBreaking: false,
});
if (result.status === "breaking") {
// We've already backfilled — safe to force migrate
const activeSchema = await backend.getActiveSchema(graph.id);
await migrateSchema(backend, graph, activeSchema!.version);
}

Update application code to stop creating the deprecated node type. Existing data remains.

Delete edges that reference the deprecated node type, then delete the nodes themselves:

// Delete all edges connected to deprecated nodes
const deprecated = await store.query(OldNode).execute();
for (const node of deprecated) {
await store.nodes.OldNode.delete(node.id);
}

Remove the node type from defineGraph() and force migrate.

Change age from z.string() to z.number():

const Person = defineNode("Person", {
schema: z.object({
age: z.string(),
ageNumeric: z.number().optional(),
}),
});
const people = await store.query(Person).execute();
for (const person of people) {
if (person.properties.ageNumeric === undefined) {
await store.nodes.Person.update(person.id, {
ageNumeric: parseInt(person.properties.age, 10),
});
}
}

Remove age, rename ageNumeric to age with the new type, and force migrate.

Switching embedding models usually changes the vector dimension (e.g. embedding(1536)embedding(3072)). The stored vectors are invalid under the new dimension — they must be recomputed, not converted — so this is handled out-of-band from the schema diff. Update the field’s embedding(N) in the schema, then call store.reembedVectorField(). It drops and recreates the field’s per-(graphId, kind, field) tg_vec_* storage at the new dimension and, when you pass an embed callback, pages the kind’s nodes and re-embeds them:

const result = await store.reembedVectorField("Document", "embedding", {
embed: async (nodes) => {
const texts = nodes.map((node) => node.content); // schema fields are top-level
const vectors = await batchEmbed(texts); // your new model
return new Map(nodes.map((node, index) => [node.id, vectors[index]]));
},
});
// result.recreated === true, result.reembedded === <count of re-embedded nodes>

Without an embed callback, the storage is recreated empty and you re-embed via normal update() writes. Until a field is re-embedded at the new dimension, a stray write at the old dimension throws EmbeddingDimensionChangedError.

Use getSchemaChanges() in CI to catch breaking changes before they reach production.

import { getSchemaChanges } from "@nicia-ai/typegraph/schema";
async function checkSchema(backend: GraphBackend, graph: GraphDef) {
const diff = await getSchemaChanges(backend, graph);
if (!diff) {
console.log("No existing schema — first deploy");
return;
}
if (!diff.hasChanges) {
console.log("Schema unchanged");
return;
}
console.log("Schema changes detected:");
console.log(diff.summary);
for (const change of [...diff.nodes, ...diff.edges]) {
const icon =
change.severity === "safe"
? "[safe]"
: change.severity === "warning"
? "[warn]"
: "[BREAKING]";
console.log(` ${icon} ${change.details}`);
}
if (diff.hasBreakingChanges) {
console.error("Breaking changes require migration before deploy.");
process.exit(1);
}
}

Before deploying to production, run against a staging database that mirrors production schema state:

const [store, result] = await createStoreWithSchema(graph, stagingBackend);
switch (result.status) {
case "initialized":
console.log("Staging DB was empty — initialized");
break;
case "migrated":
console.log(
`Auto-migrated v${result.fromVersion} → v${result.toVersion}`,
);
console.log("Changes:", result.diff.summary);
break;
case "breaking":
console.error("Would break in production. Fix before deploying.");
process.exit(1);
break;
}

Test that your migration code handles existing data correctly:

import { createStoreWithSchema, defineGraph, defineNode } from "@nicia-ai/typegraph";
import { createTestBackend } from "./test-utils";
it("migrates name to fullName", async () => {
const backend = createTestBackend();
// Set up v1 with data
const graphV1 = defineGraph({
id: "test",
nodes: { Person: { type: PersonV1 } },
edges: {},
});
const [storeV1] = await createStoreWithSchema(graphV1, backend);
await storeV1.nodes.Person.create({ name: "Alice" });
// Migrate to v2 (expand phase)
const graphV2 = defineGraph({
id: "test",
nodes: { Person: { type: PersonV2WithBothFields } },
edges: {},
});
const [storeV2, result] = await createStoreWithSchema(graphV2, backend);
expect(result.status).toBe("migrated");
// Run backfill
const people = await storeV2.query(PersonV2WithBothFields).execute();
for (const person of people) {
await storeV2.nodes.Person.update(person.id, {
fullName: person.properties.name,
});
}
// Verify
const updated = await storeV2.query(PersonV2WithBothFields).execute();
expect(updated[0].properties.fullName).toBe("Alice");
});

Use getSchemaChanges() to see what would change without modifying the database:

import { getSchemaChanges } from "@nicia-ai/typegraph/schema";
const diff = await getSchemaChanges(backend, newGraph);
if (diff?.hasChanges) {
console.log("Pending changes:", diff.summary);
console.log("Breaking:", diff.hasBreakingChanges);
for (const change of diff.nodes) {
console.log(` ${change.severity}: ${change.details}`);
}
}

TypeGraph preserves all schema versions in the typegraph_schema_versions table. Only one version is active at a time.

typegraph_schema_versions
├── version 1 (initial) ← inactive
├── version 2 (added email) ← inactive
├── version 3 (added Company) ← active

Access version history through the backend:

// Get a specific version
const v1 = await backend.getSchemaVersion("my_app", 1);
console.log("V1 created at:", v1?.created_at);
// Get the active version
const active = await backend.getActiveSchema("my_app");
console.log("Current version:", active?.version);
Change Classification Auto-Migrated?
Add node type Safe Yes
Add edge type Safe Yes
Add optional property Safe Yes
Add ontology relation Safe Yes
Change kind annotations Safe Yes
Add required property Breaking No
Remove property Breaking No
Remove node/edge type Breaking No
Rename node/edge type Breaking No
Change property type Breaking No
Change onDelete behavior Warning Yes
Change unique constraints Warning Yes
Change edge cardinality Warning Yes
Change edge endpoint kinds Warning Yes

If a deployment goes wrong, you can switch back to a previous schema version. Version history is always preserved — rollbackSchema() simply changes which version is active.

import { rollbackSchema } from "@nicia-ai/typegraph/schema";
// Roll back to version 2
await rollbackSchema(backend, "my_app", 2);

This does not delete newer versions. You can migrate forward again later.

Use onBeforeMigrate and onAfterMigrate for observability — logging, metrics, and alerts during schema migrations:

const [store, result] = await createStoreWithSchema(graph, backend, {
onBeforeMigrate: (context) => {
console.log(`Migrating ${context.graphId} v${context.fromVersion} → v${context.toVersion}`);
console.log("Changes:", context.diff.summary);
},
onAfterMigrate: (context) => {
console.log(`Migration complete: v${context.toVersion}`);
metrics.increment("schema_migrations_total");
},
});

For data transformations (backfill scripts), run them explicitly after store creation rather than inside hooks. This gives you control over retries and error handling:

const [store, result] = await createStoreWithSchema(graph, backend);
if (result.status === "migrated" && result.toVersion === 3) {
// Backfill fullName from name for the expand phase
const people = await store.query(Person).execute();
for (const person of people) {
if (!person.properties.fullName) {
await store.nodes.Person.update(person.id, {
fullName: person.properties.name,
});
}
}
}

Embeddings live in per-(graphId, kind, field) tables (tg_vec_*), provisioned by the privileged migrator (createStoreWithSchema, or evolve() for a runtime-added field). When you remove an embedding() field from a surviving kind, the schema change commits fast but the field’s now-orphaned vector table remains until you reconcile it. store.materializeRemovals() drops it — and clears its durable contribution marker so a later re-add re-provisions cleanly (this is the same pass that cleans up storage for fully removed kinds):

const result = await store.materializeRemovals();
for (const reclaimed of result.reclaimedVectorFields) {
// → { kind: "Document", fieldPath: "embedding", status: "reclaimed" }
console.log(`Dropped vector table for ${reclaimed.kind}.${reclaimed.fieldPath}`);
}

The pass is idempotent and derived from immutable schema history, so re-running it lists the same removed fields and the underlying DROP ... IF EXISTS is a no-op on subsequent calls.

  • No automatic data transformation. TypeGraph tracks schema metadata changes but does not transform existing rows. Use backfill scripts (or onAfterMigrate hooks) for data migration.
  • No rename detection. Renaming a property looks like a removal + addition. Use the expand-contract pattern instead.
  • Schema-level only. Migrations operate on the graph definition, not on underlying database tables. TypeGraph’s storage tables are schema-agnostic (nodes and edges are stored as JSON properties), so “schema migration” means updating the schema document that TypeGraph tracks, not running ALTER TABLE.