Schema Migrations
For a practical guide on evolving schemas across deployments, see Evolving Schemas in Production.
When Do You Need Schema Management?
Section titled “When Do You Need Schema Management?”As your application evolves, your graph schema changes:
- Adding features: New node types, new properties, new relationships
- Refactoring: Renaming types, changing property formats
- Deploying safely: Ensuring schema changes don’t break running applications
Without schema management, you’d face:
- No way to know if the database matches your code
- Silent failures when property names change
- Manual migration scripts for every deployment
TypeGraph’s schema management:
- Stores the schema in the database alongside your data
- Detects changes between your code and the stored schema
- Auto-migrates safe changes (adding types, optional properties)
- Blocks breaking changes until you handle them explicitly
How It Works
Section titled “How It Works”TypeGraph stores your graph schema in the database, enabling version tracking, safe migrations, and runtime introspection.
When you create a store with createStoreWithSchema(), TypeGraph:
- Creates the base tables if the database is fresh (auto-bootstrap)
- Serializes your graph definition to JSON
- Compares it with the stored schema (if any)
- Returns the result so you can act on it
Schema Lifecycle
Section titled “Schema Lifecycle”When you create a store, TypeGraph can automatically manage schema versions:
import { createStoreWithSchema } from "@nicia-ai/typegraph";
const [store, result] = await createStoreWithSchema(graph, backend);
switch (result.status) { case "initialized": console.log(`Schema initialized at version ${result.version}`); break; case "unchanged": console.log(`Schema unchanged at version ${result.version}`); break; case "migrated": console.log(`Migrated from v${result.fromVersion} to v${result.toVersion}`); break; case "pending": console.log(`Safe changes pending at version ${result.version}`); break; case "breaking": console.log("Breaking changes detected:", result.actions); break;}Basic vs Managed vs Verified Store
Section titled “Basic vs Managed vs Verified Store”TypeGraph provides three ways to create a store, each suited to a different deployment role:
Basic Store (No Schema Management)
Section titled “Basic Store (No Schema Management)”Use createStore() when you manage schema versions yourself:
import { createStore } from "@nicia-ai/typegraph";
const store = createStore(graph, backend);// No schema versioning or write fence - you handle migrations manuallyBecause a basic Store has no committed schema-version metadata, its writes do not participate in the schema-version fence. Direct backend writes have the same raw semantics. Use this mode only when the application accepts responsibility for quiescing writers around schema changes.
Managed Store (Automatic Schema Management)
Section titled “Managed Store (Automatic Schema Management)”Use createStoreWithSchema() for automatic version tracking:
import { createStoreWithSchema } from "@nicia-ai/typegraph";
const [store, result] = await createStoreWithSchema(graph, backend, { autoMigrate: true, // Auto-apply safe changes (default: true) throwOnBreaking: true, // Throw on breaking changes (default: true) onBeforeMigrate: (context) => { console.log(`Migrating ${context.graphId} from v${context.fromVersion} to v${context.toVersion}`); }, onAfterMigrate: (context) => { console.log(`Migration complete: v${context.toVersion}`); },});Verified Store (Zero-DDL Attach With Verification Gate)
Section titled “Verified Store (Zero-DDL Attach With Verification Gate)”Use createVerifiedStore() at runtime when the application runs under a
least-privilege, DML-only database role and a separate privileged step
has already advanced the schema. It is the runtime counterpart of
createStoreWithSchema(): a synchronous-semantics attach that issues
no DDL and fails fast if the database is not at the same schema
version as the code graph.
import { createVerifiedStore } from "@nicia-ai/typegraph";
// Runtime — least-privilege, DML-only role. Zero DDL.const [store, result] = await createVerifiedStore(graph, backend);// result.status === "unchanged" on success.It throws:
BaseSchemaMigrationErrorif deployment-wide base storage is missing, stale, or newer than the running library. Its details reportinstalledVersion,requiredVersion, andreason.ConfigurationErrorif no schema has been initialized (run the privileged migration step first).MigrationErrorif the persisted schema is behind the code graph by any pending change (safe or breaking) — the least-privilege runtime cannot migrate.StoreNotInitializedErrorif the schema is current but the runtime-contribution markers (e.g. fulltext) are missing/stale.
The attach itself can succeed on a non-transactional or custom backend. On a
backend whose capabilities.execution.unitOfWork is "batch" (Cloudflare
D1, Neon HTTP), a fused write commonly succeeds — see
The guard every fused write shares
for which writes fuse — and a write that cannot fuse throws
ConfigurationError with details.code === "SCHEMA_WRITE_FENCE_UNSUPPORTED",
or, for a proven need such as an interactive callback or a schema commit, a
typed error naming BATCH_WRITE_UNSUPPORTED under details.batchRefusal.
On any other backend that provides neither an interactive transaction nor
the schema-write fence, every managed write throws ConfigurationError with
details.code === "SCHEMA_WRITE_FENCE_UNSUPPORTED". Reads remain available.
If you only need the check without building a Store (e.g. a readiness
probe), call assertSchemaCurrent(backend, graph) directly — it returns
the same SchemaValidationResult or throws the same errors.
Which Stores are schema-managed?
Section titled “Which Stores are schema-managed?”A Store is schema-managed when it carries committed schema metadata:
store.introspect().schemaVersion !== undefined. The following paths create or
preserve that state:
createStoreWithSchema()andcreateAdapterStoreWithSchema()createVerifiedStore()andcreateVerifiedAdapterStore()createAdapterStore(..., { reconciled })with a cached reconciled snapshot- Stores returned by
evolve()and Stores rebound from an already-managed Store
Managed writes acquire a transaction-scoped fence and revalidate that version before changing graph data. On the official SQLite and PostgreSQL backends this prevents a stale Store write from landing across a schema commit. A custom or non-transactional backend fails closed on the first managed write that cannot fuse the fence into its own statement — see The guard every fused write shares for which writes fuse and which refuse.
createStore() and createAdapterStore() without { reconciled } are raw,
unversioned attaches. Their writes—and calls made directly through a backend—do
not participate in the fence. store.clear() deletes the graph’s schema rows
and resets that Store to the same raw state; reopen it through a managed factory
before resuming writes when the versioned guarantee is required.
Store lifetime after a schema commit
Section titled “Store lifetime after a schema commit”Managed Stores are immutable schema snapshots. A schema-changing operation such
as evolve() returns the Store for the resulting schema; it does not update the
instance on which it was called. Switch immediately to the returned Store for
all subsequent work in the same request:
const evolved = await store.evolve(extension);await evolved.getNodeCollectionOrThrow("Paper").create({ title: "..." });For a long-lived local handle, pass a StoreRef and use either the return value
or the updated ref.current after the call:
import type { StoreRef } from "@nicia-ai/typegraph";
const ref: StoreRef<typeof store> = { current: store };const evolved = await ref.current.evolve(extension, { ref });
// `ref.current === evolved`; do not resume through the pre-evolve Store.await ref.current.getNodeCollectionOrThrow("Paper").create({ title: "..." });Capturing ref.current once at request entry is safe only for requests that do
not change the schema. The ref also cannot observe commits made by another
process or isolate. Before reusing a cross-request cache, compare the cached
Store’s introspect().schemaVersion (or its reconciled snapshot version) with
getCommittedSchemaVersion(), then run createVerifiedStore() or
createVerifiedAdapterStore() when the version changes. The
per-request connection recipe
shows the complete single-flight cache pattern.
Schema Validation Results
Section titled “Schema Validation Results”The validation result indicates what happened during store initialization:
| Status | Meaning |
|---|---|
initialized |
First run - schema version 1 was created |
unchanged |
Schema matches stored version - no changes |
migrated |
Safe changes auto-applied, new version created |
pending |
Safe changes detected but autoMigrate is false |
breaking |
Breaking changes detected, action required |
The initialized and migrated results also include
committedRow: SchemaVersionRow, the schema row that was just written. Most
applications only need the version fields shown above, but integrations that
build schema metadata can use committedRow without issuing another
getActiveSchema read.
Safe vs Breaking Changes
Section titled “Safe vs Breaking Changes”Safe Changes (Auto-Migrated)
Section titled “Safe Changes (Auto-Migrated)”These changes are backwards compatible and can be auto-migrated:
- Adding new node types
- Adding new edge types
- Adding optional properties with defaults
- Adding new ontology relations
Breaking Changes (Require Manual Action)
Section titled “Breaking Changes (Require Manual Action)”These changes require manual migration:
- Removing node or edge types
- Renaming node or edge types
- Changing property types
- Removing properties
- Changing cardinality constraints to be more restrictive
- Removing allowed endpoint pairs from a source-dependent edge
Endpoint Pair Changes
Section titled “Endpoint Pair Changes”Source-dependent targets are part of
the serialized schema. The targetKindsBySource field preserves the allowed
pairs alongside the source and target kind lists, so export/import and schema
round trips retain the restriction. For compile-time declarations, reordering
map entries or target arrays does not change the schema hash. Persisted runtime
extension documents also contribute to the hash and retain their array order.
Narrowing a target map is breaking even when the overall source and target kind
sets remain unchanged. For example, changing an edge from allowing every
Employee/Student to Department/Course combination to allowing only
Employee → Department and Student → Course removes two pairs. Existing rows
using those pairs need migration before adopting the narrower schema.
Adding allowed pairs, or changing the representation without removing any pairs, is nonbreaking. Runtime extension changes have an additional empty-kind check when tightening endpoints; see extension edges.
Handling Breaking Changes
Section titled “Handling Breaking Changes”When breaking changes are detected:
const [store, result] = await createStoreWithSchema(graph, backend, { throwOnBreaking: false, // Don't throw, inspect instead});
if (result.status === "breaking") { console.log("Breaking changes detected:"); console.log("Summary:", result.diff.summary); console.log("Required actions:"); for (const action of result.actions) { console.log(` - ${action}`); }
// Option 1: Fix your schema to be backwards compatible
// Option 2: Force migration (data loss possible!) // import { migrateSchema } from "@nicia-ai/typegraph/schema"; // await migrateSchema(backend, graph, currentVersion);}Pre-flighting before you commit
Section titled “Pre-flighting before you commit”Both checks below are SELECT-only — no DDL, no writes — so a least-privilege runtime can decide what to do before it hits the privileged migration wall:
import { classifySchemaChanges } from "@nicia-ai/typegraph/schema";
// Cheapest: does this need the privileged path at all?// (true when the schema is behind, and when nothing is committed yet)if (await store.requiresMigration()) { // Route to the privileged bootstrap instead of failing mid-request.}
// Or get the three-way decision:const diff = await store.schemaChanges();const classification = diff === undefined ? "uninitialized" : classifySchemaChanges(diff);// "identical" | "additive" | "incompatible"Classifying a failure
Section titled “Classifying a failure”If a commit does fail, branch on the structured outcome rather than the message text, which is free to be reworded in any release:
import { MigrationError } from "@nicia-ai/typegraph";
try { await commitSomething();} catch (error) { if (error instanceof MigrationError) { switch (error.details.reason) { case "schema-behind": { // The runtime can't migrate. `diff` says whether it's safe to proceed. const additive = error.details.diff?.hasBreakingChanges === false; break; } case "breaking-change": { break; } case "kind-removal": { // The commit would drop a kind that still holds rows. Narrowing on // `reason` makes `droppedKinds` non-optional — the details type is a // discriminated union, so each reason carries exactly its own payload. const { nodes, edges } = error.details.droppedKinds; console.error("still populated:", [...nodes, ...edges]); break; } // "no-active-version" | "version-not-found" } }}details.reason is a stable discriminant (the MIGRATION_FAILURE_REASONS
union), and details.diff carries the same structured diff — with per-change
severity — that getSchemaChanges returns, so you never need a second query
to decide.
Schema Introspection
Section titled “Schema Introspection”What Does This Database Already Have?
Section titled “What Does This Database Already Have?”getActiveSchema returns the committed schema document — the same JSON stored
in typegraph_schema_versions.schema_doc, parsed into a SerializedSchema.
Read it instead of querying that table by hand:
import { getActiveSchema, isSchemaInitialized, type SerializedSchema } from "@nicia-ai/typegraph";
// Check whether this graph has been committed at allconst initialized = await isSchemaInitialized(backend, "my_graph");
const schema: SerializedSchema | undefined = await getActiveSchema(backend, "my_graph");if (schema) { console.log("Version:", schema.version); console.log("Nodes:", Object.keys(schema.nodes)); // ["Person", "Company"] console.log("Edges:", Object.keys(schema.edges)); // ["worksAt"]}These are exported from both the package root and the
@nicia-ai/typegraph/schema subpath. Reach for getCommittedSchemaVersion
instead when you only need the version number — for example, to invalidate a
cached schema across isolates.
Previewing Pending Changes
Section titled “Previewing Pending Changes”import { getSchemaChanges } from "@nicia-ai/typegraph/schema";
const diff = await getSchemaChanges(backend, graph);if (diff?.hasChanges) { console.log("Pending changes:", diff.summary); console.log("Is backwards compatible:", !diff.hasBreakingChanges);}Manual Migration
Section titled “Manual Migration”For full control over migrations:
import { initializeSchema, migrateSchema, rollbackSchema, ensureSchema } from "@nicia-ai/typegraph/schema";
// Initialize schema (first run only)const row = await initializeSchema(backend, graph);console.log("Created version:", row.version);
// Migrate to new version. Folds the persisted graph extension into `graph`// first, and refuses (MigrationError, reason "kind-removal") if the commit// would drop a kind that still holds rows.const newVersion = await migrateSchema(backend, graph, currentVersion);console.log("Migrated to version:", newVersion);
// Rollback to a previous versionawait rollbackSchema(backend, "my_graph", 1);console.log("Rolled back to version 1");
// Or use ensureSchema for automatic handlingconst result = await ensureSchema(backend, graph, { autoMigrate: true, throwOnBreaking: true,});Migrating Legacy Embedding Storage
Section titled “Migrating Legacy Embedding Storage”Embeddings now live in per-(graphId, kind, field) typed tables
(tg_vec_<graphId>_<kind>_<field>), provisioned by createStoreWithSchema (the
privileged migrator) at boot. This replaces the single shared
typegraph_node_embeddings table. New deployments need no action — the per-field
tables are materialized by createStoreWithSchema, which the legacy migration
below also relies on having run.
Deployments that already hold rows in the legacy table run a one-time, idempotent
cutover with migrateLegacyEmbeddings(), exported from the package root:
import { migrateLegacyEmbeddings } from "@nicia-ai/typegraph";
// `backend` is the post-cutover backend, wired with its VectorStrategy.const result = await migrateLegacyEmbeddings({ backend });
console.log("Rows migrated:", result.migrated);console.log("Per field:", result.perField);console.log("Skipped (dimension mismatch):", result.skippedDimensionMismatch);console.log("Legacy table existed:", result.legacyTablePresent);The run re-inserts every legacy embedding into per-field storage and is a clean
no-op on a fresh install or a re-run (legacyTablePresent: false). A non-empty
skippedDimensionMismatch flags (kind, field) slots that held mixed dimensions
and need a deliberate re-embed at a single dimension — see
reembedVectorField.
The vector and hybrid query API (.similarTo(), store.search.vector,
store.search.hybrid) is storage-transparent and unchanged by this cutover.
Migrating Preview Recorded Time
Section titled “Migrating Preview Recorded Time”The initial recorded-time preview stored timestamps directly in
recorded_from, recorded_to, and the graph clock. Versioned anchors now keep
the durable string API while recorded relations compare numeric revisions.
Stop writers and run the one-time migration before enabling history: true
with the new library version. createStoreWithSchema and
createVerifiedStore validate the recorded table shapes during an async open
and reject an unmigrated preview schema before returning a store:
import { deleteLegacyRecordedAnchorMap, migrateLegacyRecordedTime, migrateRecordedAnchor,} from "@nicia-ai/typegraph";
const result = await migrateLegacyRecordedTime({ backend });console.log(result.graphs, result.anchors);
// Translate anchors stored in an application-owned checkpoint table.const upgraded = await migrateRecordedAnchor({ backend, graphId: "event-materializer", anchor: oldTimestampOnlyAnchor,});await checkpoints.replaceAnchor(oldTimestampOnlyAnchor, upgraded);
// Do this only after every external checkpoint for the graph is upgraded.await deleteLegacyRecordedAnchorMap({ backend, graphId: "event-materializer", dropWhenEmpty: true,});The bundled SQLite and PostgreSQL backends provide the recorded-relation DDL needed by this
rewrite. A custom backend that created the preview schema must implement
backend.recordedTableDdl(tableNames) before running migrateLegacyRecordedTime; otherwise the
migration throws UnsupportedBackendCapabilityError with
details.capability: "recordedTableDdl". The callback is invoked for the temporary and final name
sets so the backend, rather than TypeGraph’s portable entrypoint, remains the owner of
dialect-specific table and index DDL.
When the engine names primary-key constraints, each callback result must name the constraint for
both name sets or for neither. A one-sided declaration throws ConfigurationError with
details.code: "RECORDED_DDL_CONSTRAINT_NAME_MISMATCH" before the replacement tables are
published. See
recordedTableDdl in the backend contract
when adapting this migration to a custom backend.
The migration dense-ranks distinct legacy commit timestamps independently per
graph, preserving their exact total order. It rewrites the recorded relations
and clock atomically and retains a durable old-anchor mapping so downstream
stores can migrate separately. Re-running it after the cutover is a no-op.
migrateRecordedAnchor also accepts an already-versioned r1 anchor, making a
mixed old/new checkpoint pass idempotent.
The synchronous createStore factory is an attach-only, zero-I/O path, so it
cannot inspect table shapes during construction. If used with history: true,
an unmigrated schema still fails loudly on the first recorded operation. Prefer
one of the async factories above at application startup when early schema
verification matters.
The old allocator may have pushed a hot graph’s physical timestamp ahead of real wall time. Migration preserves that value because lowering it would put the clock behind recorded relation boundaries. New commits advance the logical revision normally, while the physical component remains pinned until wall time catches up. During that window, diagonal reads use the inherited future valid time; recorded-only ordering and replay remain exact.
The mapping is graph-scoped: the same timestamp can correspond to different
revisions in different graphs. Keep writers stopped for the schema rewrite, and
delete mapping rows only after every external checkpoint for that graph has
been translated. dropWhenEmpty: true atomically drops the mapping table when
the deleted graph was the final one. Without that option, the empty table is
retained intentionally and can be dropped by your normal migration tooling.
Repairing Inverted Validity Windows
Section titled “Repairing Inverted Validity Windows”Older library versions could store a row whose validity window runs backwards
(valid_from > valid_to). Such a row is readable at no coordinate at all:
asOf(t) needs valid_from <= t < valid_to, and backwards bounds admit no t.
The write paths no longer produce one — a write that stamps a lower bound the
caller did not state now stores no bound rather than an inverting one, see
Open-left rows — but
upgrading rewrites nothing. Rows already stored that way keep their window
and stay invisible until an operator repairs them, which is deliberate: an
upgrade that silently made previously-invisible rows appear in historical
queries would be the worse surprise.
repairInvertedValidityWindows is that explicit action. It has two modes:
report counts and writes nothing, apply normalizes the rows it counted to
valid_from = NULL (“ended at T, start unknown”).
import { repairInvertedValidityWindows } from "@nicia-ai/typegraph";
// Diagnose. `report` reads through `execute`, a required backend member, so it// runs against ANY backend — including a history-capturing one and one with no// statement-execution support.const report = await repairInvertedValidityWindows({ backend: anyBackend, relations: "live-and-recorded", mode: "report",});// report.counts.recordedNodes === undefined means NOT SCANNED, never "clean".// report.atomic === false means the counts came from per-relation snapshots.
// Repair, with writers stopped. On a history-enabled store pass the RAW backend// you constructed it from: the repair mints no revision by design, and the// capture wrapper refuses raw statements.await repairInvertedValidityWindows({ backend: rawBackend, relations: "live-and-recorded", mode: "apply",});If tableNames is supplied, it patches backend.tableNames; unstated relation
names keep the backend’s configured values. A partial override never sends the
other relations back to TypeGraph’s built-in defaults.
relations is required, and "live-and-recorded" is the recommended scope.
Repairing only the live axis leaves the recorded twin carrying the inverted
window, which re-materializes the invisible row at any asOfRecorded
coordinate — the same defect one axis over. "live" is right in exactly two
cases: the store captures no history and the recorded_* tables do not exist
(scanning them is then an error, not a no-op), or you are deliberately keeping
the recorded axis as an audit record of the pre-repair state and accept that
historical asOfRecorded reads keep returning the invisible shape.
What an operator must know before running it:
- Run
applywith writers stopped, the same guidancemigrateLegacyRecordedTime()carries. A concurrent window-bearing update may fence its write on the validity lower bound it read, so a repair landing in between can make the peer’s firstUPDATEmatch no row. Store node and edge updates re-read and re-judge against the repaired bound; interchange records a per-row target-changed error instead of claiming the row was written.reportneeds no quiescing: it scans in a read-only transaction (BEGINrather than SQLite’s writer-reservingBEGIN IMMEDIATE, andBEGIN … READ ONLYon PostgreSQL), so it cannot write itself. - Repaired rows become visible at
asOfcoordinates before their end. That is the point, and it is a read-visibility change to historical queries. - Outstanding
base@Vmerge tokens are invalidated for repaired rows —valid_fromis part of the base content fingerprint, so a merge whose base token predates the repair fails its precondition afterwards. Quiesce merges, repair, then re-baseline branches. - The repair mints no revision and bumps no
version, and does not moveupdated_at. It normalizes a storage convention for rows that were never observable at any coordinate; it is not a logical write. That is whyapplyis run against the raw backend, and why bypassing recorded-time capture here is intended rather than a workaround. applyrefuses when a scanned relation stores non-canonical bounds (SQLite only — PostgreSQL storestimestamptz, so a scanned relation always reportsnonCanonical: 0). SQLite compares the bounds as text, so a non-canonical value cannot be classified without a timestamp semantics this repair does not own. The refusal is total: the whole call is rejected before any row is updated, soapplynever repairs the rows it understood and skips the rest.reportstill counts them, innonCanonical— normalize those bounds, or narrow the call withgraphId, and re-run.- On a backend without transactions the call still runs, per relation, and
says so with
report.atomic === false: the counts may span snapshots, and a crash mid-applycan leave the live axis repaired and the recorded axis not. Re-run — each statement is idempotent and convergent, and a laterreportproves it converged. - Repair before exporting a legacy graph. An exported inverted row is refused per row on re-import, so an unrepaired graph does not round-trip.
The statement touches only rows the library mis-stored, so it is empty on a
healthy graph and needs no batching. If a report returns a count large enough to
worry about, narrow the call with graphId and run it per graph.
Schema Serialization
Section titled “Schema Serialization”Schemas are stored as JSON documents with computed hashes for fast comparison:
import { serializeSchema, computeSchemaHash } from "@nicia-ai/typegraph/schema";
// Serialize a graph definitionconst serialized = serializeSchema(graph, 1);
// Compute hash for comparisonconst hash = computeSchemaHash(serialized);The serialized schema includes:
- Graph ID and version
- All node types with their Zod schemas (as JSON Schema)
- All edge types with endpoints and constraints
- Complete ontology relations
- Uniqueness constraints and delete behaviors
Version History
Section titled “Version History”TypeGraph maintains a history of all schema versions:
typegraph_schema_versions├── version 1 (initial)├── version 2 (added User node)├── version 3 (added email property) ← active└── ...Only one version is marked as “active” at a time. Previous versions are preserved for auditing and potential rollback.
Best Practices
Section titled “Best Practices”1. Use Managed Stores in Production
Section titled “1. Use Managed Stores in Production”// Production: Use schema managementconst [store, result] = await createStoreWithSchema(graph, backend);
// Development: Basic store is fine for rapid iterationconst store = createStore(graph, backend);2. Check Migration Status on Startup
Section titled “2. Check Migration Status on Startup”async function initializeApp() { const [store, result] = await createStoreWithSchema(graph, backend);
if (result.status === "breaking") { console.error("Database schema incompatible with application!"); console.error("Run migrations before deploying this version."); process.exit(1); }
if (result.status === "migrated") { console.log(`Schema auto-migrated to v${result.toVersion}`); }
return store;}3. Preview Changes Before Deployment
Section titled “3. Preview Changes Before Deployment”import { getSchemaChanges } from "@nicia-ai/typegraph/schema";
// In your CI/CD pipeline or migration scriptconst diff = await getSchemaChanges(backend, graph);
if (diff?.hasChanges) { console.log("Schema changes detected:"); console.log(diff.summary);
if (!diff.isBackwardsCompatible) { console.error("Breaking changes require manual migration!"); process.exit(1); }}4. Add Properties with Defaults
Section titled “4. Add Properties with Defaults”When adding new properties, always provide defaults to ensure backwards compatibility:
// Good: Optional with defaultconst User = defineNode("User", { schema: z.object({ name: z.string(), // New property with default - safe migration status: z.enum(["active", "inactive"]).default("active"), }),});
// Bad: Required without default - breaking changeconst User = defineNode("User", { schema: z.object({ name: z.string(), status: z.enum(["active", "inactive"]), // No default! }),});