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.
How Schema Evolution Works
Section titled “How Schema Evolution Works”When you call createStoreWithSchema(), TypeGraph:
- Serializes your current graph definition
- Compares it against the stored schema (by hash, then by diff)
- Safe changes — auto-migrates and bumps the version
- Breaking changes — throws
MigrationError(or returnsstatus: "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.
Safe Changes
Section titled “Safe Changes”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
Adding an Optional Property
Section titled “Adding an Optional Property”// Version 1const Person = defineNode("Person", { schema: z.object({ name: z.string(), }),});
// Version 2 — safe, auto-migratesconst 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.
Adding a Node Type with Edges
Section titled “Adding a Node Type with Edges”// Version 2 — add Company and worksAt in one deployconst 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.
Breaking Changes
Section titled “Breaking Changes”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.
The Expand-Contract Pattern
Section titled “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.
Renaming a Property
Section titled “Renaming a Property”Rename name to fullName on Person in three deploys:
Deploy 1 — Expand: add the new property
Section titled “Deploy 1 — Expand: add the new property”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);}Removing a Node Type
Section titled “Removing a Node Type”Deploy 1 — Stop creating new instances
Section titled “Deploy 1 — Stop creating new instances”Update application code to stop creating the deprecated node type. Existing data remains.
Deploy 2 — Clean up references
Section titled “Deploy 2 — Clean up references”Delete edges that reference the deprecated node type, then delete the nodes themselves:
// Delete all edges connected to deprecated nodesconst deprecated = await store.query(OldNode).execute();for (const node of deprecated) { await store.nodes.OldNode.delete(node.id);}Deploy 3 — Remove from schema
Section titled “Deploy 3 — Remove from schema”Remove the node type from defineGraph() and force migrate.
Changing a Property Type
Section titled “Changing a Property Type”Change age from z.string() to z.number():
Deploy 1 — Add the new property
Section titled “Deploy 1 — Add the new property”const Person = defineNode("Person", { schema: z.object({ age: z.string(), ageNumeric: z.number().optional(), }),});Deploy 2 — Backfill and switch
Section titled “Deploy 2 — Backfill and switch”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), }); }}Deploy 3 — Contract
Section titled “Deploy 3 — Contract”Remove age, rename ageNumeric to age with the new type, and force migrate.
Pre-Deploy Schema Checks
Section titled “Pre-Deploy Schema Checks”Use getSchemaChanges() in CI to catch breaking changes before they reach
production.
CI/CD Script
Section titled “CI/CD Script”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); }}Staging Validation
Section titled “Staging Validation”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;}Testing Schema Changes
Section titled “Testing Schema Changes”Unit Testing Migrations
Section titled “Unit Testing Migrations”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");});Previewing Changes Without Applying
Section titled “Previewing Changes Without Applying”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}`); }}Version History
Section titled “Version History”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) ← activeAccess version history through the backend:
// Get a specific versionconst v1 = await backend.getSchemaVersion("my_app", 1);console.log("V1 created at:", v1?.created_at);
// Get the active versionconst active = await backend.getActiveSchema("my_app");console.log("Current version:", active?.version);Summary: Change Classification
Section titled “Summary: Change Classification”| Change | Classification | Auto-Migrated? |
|---|---|---|
| Add node type | Safe | Yes |
| Add edge type | Safe | Yes |
| Add optional property | Safe | Yes |
| Add ontology relation | 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 |
Rollback
Section titled “Rollback”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 2await rollbackSchema(backend, "my_app", 2);This does not delete newer versions. You can migrate forward again later.
Migration Hooks
Section titled “Migration Hooks”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, }); } }}Current Limitations
Section titled “Current Limitations”- No automatic data transformation. TypeGraph tracks schema metadata
changes but does not transform existing rows. Use backfill scripts (or
onAfterMigratehooks) 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 metadata that TypeGraph tracks, not
running
ALTER TABLE.