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.

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);
ChangeClassificationAuto-Migrated?
Add node typeSafeYes
Add edge typeSafeYes
Add optional propertySafeYes
Add ontology relationSafeYes
Change kind annotationsSafeYes
Add required propertyBreakingNo
Remove propertyBreakingNo
Remove node/edge typeBreakingNo
Rename node/edge typeBreakingNo
Change property typeBreakingNo
Change onDelete behaviorWarningYes
Change unique constraintsWarningYes
Change edge cardinalityWarningYes
Change edge endpoint kindsWarningYes

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,
});
}
}
}
  • 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.