# Chapter 13: Schema Evolution Data structures change over time. Fields get added, removed, or renamed. Types get split or merged. Without careful handling, these changes break systems—old data can't be read, services fail, migrations corrupt data. Lux's **schema evolution** system makes these changes safe and automatic. ## The Problem Consider a real scenario: ```lux // Version 1: Simple user type User { name: String } // Later, you need email addresses type User { name: String, email: String // Breaking change! Old data doesn't have this. } ``` In most languages, this breaks everything. Existing users in your database don't have email addresses. Deserializing old data fails. Services crash. Lux solves this with **versioned types** and **automatic migrations**. ## Versioned Types Add a version annotation to any type: ```lux // Version 1: Original definition type User @v1 { name: String } // Version 2: Added email field type User @v2 { name: String, email: String, // How to migrate from v1 from @v1 = { name: old.name, email: "unknown@example.com" } } // Version 3: Split name into first/last type User @v3 { firstName: String, lastName: String, email: String, // How to migrate from v2 from @v2 = { firstName: String.split(old.name, " ") |> List.head |> Option.getOrElse(""), lastName: String.split(old.name, " ") |> List.tail |> List.head |> Option.getOrElse(""), email: old.email } } ``` The `@latest` alias always refers to the most recent version: ```lux type User @latest { firstName: String, lastName: String, email: String, from @v2 = { ... } } // These are equivalent: fn createUser(first: String, last: String, email: String): User@latest = ... fn createUser(first: String, last: String, email: String): User@v3 = ... ``` ## Migration Syntax ### Basic Migration ```lux type Config @v2 { theme: String, fontSize: Int, // 'old' refers to the v1 value from @v1 = { theme: old.theme, fontSize: 14 // New field with default } } ``` ### Computed Fields ```lux type Order @v2 { items: List, total: Int, itemCount: Int, // New computed field from @v1 = { items: old.items, total: old.total, itemCount: List.length(old.items) } } ``` ### Removing Fields When removing fields, simply don't include them in the new version: ```lux type Settings @v1 { theme: String, legacyMode: Bool, // To be removed volume: Int } type Settings @v2 { theme: String, volume: Int, // legacyMode is dropped - just don't migrate it from @v1 = { theme: old.theme, volume: old.volume } } ``` ### Renaming Fields ```lux type Product @v1 { name: String, cost: Int // Old field name } type Product @v2 { name: String, price: Int, // Renamed from 'cost' from @v1 = { name: old.name, price: old.cost // Map old field to new name } } ``` ### Complex Transformations ```lux type Address @v1 { fullAddress: String // "123 Main St, New York, NY 10001" } type Address @v2 { street: String, city: String, state: String, zip: String, from @v1 = { let parts = String.split(old.fullAddress, ", ") { street: List.get(parts, 0) |> Option.getOrElse(""), city: List.get(parts, 1) |> Option.getOrElse(""), state: List.get(parts, 2) |> Option.map(fn(s: String): String => String.split(s, " ") |> List.head |> Option.getOrElse("")) |> Option.getOrElse(""), zip: List.get(parts, 2) |> Option.map(fn(s: String): String => String.split(s, " ") |> List.last |> Option.getOrElse("")) |> Option.getOrElse("") } } } ``` ## Working with Versioned Values The `Schema` module provides runtime operations for versioned values: ### Creating Versioned Values ```lux // Create a value tagged with a specific version let userV1 = Schema.versioned("User", 1, { name: "Alice" }) let userV2 = Schema.versioned("User", 2, { name: "Alice", email: "alice@example.com" }) ``` ### Checking Versions ```lux let user = Schema.versioned("User", 1, { name: "Alice" }) let version = Schema.getVersion(user) // Returns 1 // Version-aware logic if version < 2 then Console.print("Legacy user format") else Console.print("Modern user format") ``` ### Migrating Values ```lux // Migrate to a specific version let userV1 = Schema.versioned("User", 1, { name: "Alice" }) let userV2 = Schema.migrate(userV1, 2) // Uses declared migration let version = Schema.getVersion(userV2) // Now 2 // Chain migrations (v1 -> v2 -> v3) let userV3 = Schema.migrate(userV1, 3) // Applies v1->v2, then v2->v3 ``` ## Auto-Generated Migrations For simple changes, Lux can **automatically generate** migrations: ```lux type Profile @v1 { name: String } // Adding a field with a default? Migration is auto-generated type Profile @v2 { name: String, bio: String = "" // Default value provided } // The compiler generates this for you: // from @v1 = { name: old.name, bio: "" } ``` Auto-migration works for: - Adding fields with default values - Keeping existing fields unchanged You must write explicit migrations for: - Field renaming - Field removal (to confirm intent) - Type changes - Computed/derived fields ## Practical Examples ### Example 1: API Response Versioning ```lux type ApiResponse @v1 { status: String, data: String } type ApiResponse @v2 { status: String, data: String, meta: { timestamp: Int, version: String }, from @v1 = { status: old.status, data: old.data, meta: { timestamp: 0, version: "legacy" } } } // Version-aware API client fn handleResponse(raw: ApiResponse@v1): ApiResponse@v2 = { Schema.migrate(Schema.versioned("ApiResponse", 1, raw), 2) } ``` ### Example 2: Database Record Evolution ```lux // Original schema type Customer @v1 { name: String, address: String } // Split address into components type Customer @v2 { name: String, street: String, city: String, country: String, from @v1 = { let parts = String.split(old.address, ", ") { name: old.name, street: List.get(parts, 0) |> Option.getOrElse(old.address), city: List.get(parts, 1) |> Option.getOrElse("Unknown"), country: List.get(parts, 2) |> Option.getOrElse("Unknown") } } } // Load and migrate on read fn loadCustomer(id: String): Customer@v2 with {Database} = { let record = Database.query("SELECT * FROM customers WHERE id = ?", [id]) let version = record.schema_version // Stored version if version == 1 then let v1 = Schema.versioned("Customer", 1, { name: record.name, address: record.address }) Schema.migrate(v1, 2) else { name: record.name, street: record.street, city: record.city, country: record.country } } ``` ### Example 3: Configuration Files ```lux type AppConfig @v1 { debug: Bool, port: Int } type AppConfig @v2 { debug: Bool, port: Int, logLevel: String, // New in v2 from @v1 = { debug: old.debug, port: old.port, logLevel: if old.debug then "debug" else "info" } } type AppConfig @v3 { environment: String, // Replaces debug flag port: Int, logLevel: String, from @v2 = { environment: if old.debug then "development" else "production", port: old.port, logLevel: old.logLevel } } // Load config with automatic migration fn loadConfig(path: String): AppConfig@v3 with {File} = { let json = File.read(path) let parsed = Json.parse(json) let version = Json.getInt(parsed, "version") |> Option.getOrElse(1) match version { 1 => { let v1 = Schema.versioned("AppConfig", 1, { debug: Json.getBool(parsed, "debug") |> Option.getOrElse(false), port: Json.getInt(parsed, "port") |> Option.getOrElse(8080) }) Schema.migrate(v1, 3) }, 2 => { let v2 = Schema.versioned("AppConfig", 2, { debug: Json.getBool(parsed, "debug") |> Option.getOrElse(false), port: Json.getInt(parsed, "port") |> Option.getOrElse(8080), logLevel: Json.getString(parsed, "logLevel") |> Option.getOrElse("info") }) Schema.migrate(v2, 3) }, _ => { // Already v3 { environment: Json.getString(parsed, "environment") |> Option.getOrElse("production"), port: Json.getInt(parsed, "port") |> Option.getOrElse(8080), logLevel: Json.getString(parsed, "logLevel") |> Option.getOrElse("info") } } } } ``` ### Example 4: Event Sourcing ```lux // Event types evolve over time type UserCreated @v1 { userId: String, name: String, timestamp: Int } type UserCreated @v2 { userId: String, name: String, email: String, createdAt: Int, // Renamed from timestamp from @v1 = { userId: old.userId, name: old.name, email: "", // Not captured in v1 createdAt: old.timestamp } } // Process events regardless of version fn processEvent(event: UserCreated@v1 | UserCreated@v2): Unit with {Console} = { let normalized = Schema.migrate(event, 2) // Always work with v2 Console.print("User created: " + normalized.name + " at " + toString(normalized.createdAt)) } ``` ## Compile-Time Safety The compiler catches schema evolution errors: ```lux type User @v2 { name: String, email: String // ERROR: Migration references non-existent field from @v1 = { name: old.username, email: old.email } // ^^^^^^^^ 'username' does not exist in User@v1 } ``` ```lux type User @v2 { name: String, email: String // ERROR: Migration missing required field from @v1 = { name: old.name } // ^ Missing 'email' field } ``` ```lux type User @v2 { name: String, age: Int // ERROR: Type mismatch in migration from @v1 = { name: old.name, age: old.birthYear } // ^^^^^^^^^^^^^ Expected Int, found String } ``` ## Compatibility Checking Lux tracks compatibility between versions: | Change Type | Backward Compatible | Forward Compatible | |-------------|--------------------|--------------------| | Add optional field (with default) | Yes | Yes | | Add required field | No | Yes (with migration) | | Remove field | Yes (with migration) | No | | Rename field | No | No (need migration) | | Change field type | No | No (need migration) | The compiler warns about breaking changes: ```lux type User @v1 { name: String, email: String } type User @v2 { name: String // Warning: Removing 'email' is a breaking change // Existing v2 consumers expect this field } ``` ## Best Practices ### 1. Always Version Production Types ```lux // Good: Versioned from the start type Order @v1 { id: String, items: List, total: Int } // Bad: Unversioned type is hard to evolve type Order { id: String, items: List, total: Int } ``` ### 2. Keep Migrations Simple ```lux // Good: Simple, direct mapping from @v1 = { name: old.name, email: old.email |> Option.getOrElse("") } // Avoid: Complex logic in migrations from @v1 = { name: old.name, email: { // Don't put complex business logic here let domain = inferDomainFromName(old.name) let local = String.toLower(String.replace(old.name, " ", ".")) local + "@" + domain } } ``` ### 3. Test Migrations ```lux fn testUserMigration(): Unit with {Test} = { let v1User = Schema.versioned("User", 1, { name: "Alice" }) let v2User = Schema.migrate(v1User, 2) Test.assertEqual(v2User.name, "Alice") Test.assertEqual(v2User.email, "unknown@example.com") } ``` ### 4. Document Breaking Changes ```lux type User @v3 { // BREAKING: 'name' split into firstName/lastName // Migration: name.split(" ")[0] -> firstName, name.split(" ")[1] -> lastName firstName: String, lastName: String, email: String, from @v2 = { ... } } ``` ## Schema Module Reference | Function | Description | |----------|-------------| | `Schema.versioned(typeName, version, value)` | Create a versioned value | | `Schema.getVersion(value)` | Get the version of a value | | `Schema.migrate(value, targetVersion)` | Migrate to a target version | | `Schema.isCompatible(v1, v2)` | Check if versions are compatible | ## Summary Schema evolution in Lux provides: - **Versioned types** with `@v1`, `@v2`, `@latest` annotations - **Explicit migrations** with `from @vN = { ... }` syntax - **Automatic migrations** for simple field additions with defaults - **Runtime operations** via the `Schema` module - **Compile-time safety** catching migration errors early - **Migration chaining** for multi-step upgrades This system ensures your data can evolve safely over time, without breaking existing code or losing information. ## What's Next? - [Tutorials](../tutorials/README.md) - Build real projects - [Standard Library Reference](../stdlib/README.md) - Complete API docs