Summary

Resolves #519 - Schema Migration System for ZIO Schema 2

/claim #519

This PR implements a comprehensive schema migration system for ZIO Schema 2, addressing issue #519. The implementation provides pure, serializable migrations that enable safe, reversible transformations of data structures as schemas evolve over time.

Key Features

  • Pure Data Migrations: Migrations are represented as data structures rather than functions, enabling serialization, introspection, and automatic reversal
  • Type-Safe API: Compile-time checking with schemas ensures correctness
  • Macro Validation: The build() method validates at compile-time that migrations are complete, catching missing field mappings before runtime
  • Deep Nesting Support: Navigate and transform arbitrarily nested structures up to 7+ levels deep
  • Comprehensive Action Set: 14 migration action types covering records, variants, and collections
  • Automatic Reversal: Every migration has a structural inverse that can be generated automatically
  • Composable: Combine migrations using the ++ operator for complex transformations
  • Rich Error Reporting: Detailed path-based error messages for debugging

Implementation Highlights

Spec Compliance

  • Selector-based API (Spec line 504): User API uses selector functions (S => A) for type-safe field access with IDE support
  • Compile-time validation: Macro-based validation ensures complete migrations (adapted from PR #659 approach)
  • Structural type support: Handles case classes, sealed traits, tuples, collections, and recursive types
  • Bidirectional coercion: Automatic type conversions (e.g., Int ↔ Long, Tuple ↔ Case Class)
  • Pure data migrations: All migrations are serializable data structures, not functions

Cross-Platform Support

  • Scala 2.13 & 3.x: Full cross-compilation with version-specific macro implementations
  • JVM, JS, Native: All tests pass on all platforms
  • Type-safe macros: Scala 2 uses whitebox macros, Scala 3 uses inline macros with quotes

Implementation Details

Core Architecture

The migration system is built on three layers:

  1. Migration[A, B]: Type-safe wrapper with source and target schemas
  2. DynamicMigration: Untyped core that operates on DynamicValue
  3. MigrationAction: Individual transformation actions (14 types)

Migration Actions

Record Actions:

  • AddField: Add new fields with default values
  • DropField: Remove existing fields
  • Rename: Rename fields
  • Optionalize: Make mandatory fields optional
  • Mandate: Make optional fields mandatory
  • ChangeType: Change field types with transformations
  • TransformValue: Transform field values
  • Join: Combine multiple fields into one
  • Split: Split one field into multiple

Variant Actions:

  • RenameCase: Rename variant cases
  • TransformCase: Transform case values

Collection Actions:

  • TransformElements: Transform sequence elements
  • TransformKeys: Transform map keys
  • TransformValues: Transform map values

Builder API

The MigrationBuilder provides two complementary APIs for constructing migrations:

1. Selector-Based API (Recommended)

Type-safe, IDE-friendly API using selector functions (S => A) as specified in the requirements:

val migration = MigrationBuilder(sourceSchema, targetSchema)
.renameField(_.name, _.fullName) // Type-safe field selection
.addField(_.country, "USA") // Compile-time field validation
.dropField(_.oldField) // IDE autocomplete support
.build

Benefits:

  • Type safety: Compile errors if fields don’t exist
  • IDE support: Autocomplete and refactoring work seamlessly
  • Macro-powered: Field names extracted at compile-time from lambda expressions
  • Cross-platform: Works on both Scala 2.13 and Scala 3.x

2. String-Based API (Alternative)

Traditional string-based API for dynamic scenarios:

val migration = MigrationBuilder(sourceSchema, targetSchema)
.renameFieldByName("name", "fullName")
.addFieldWithDefault("email", "unknown@example.com")
.build

Compile-Time Validation

The build() method includes compile-time macro validation that ensures migrations are complete:

  • All source fields are handled (renamed, dropped, or implicitly carried over)
  • All target fields are produced (added, renamed, or implicitly carried over)
  • Clear compile-time error messages for incomplete migrations

A buildUnchecked() method is also available for cases where validation is not needed.

Testing

Comprehensive test suite with 66 tests covering:

  • All 14 migration action types
  • Selector-based API: 4 tests for type-safe field selection with different default value types
  • Flat structure migrations
  • Nested migrations (2-3 levels)
  • Deep nested migrations (4-7 levels)
  • Round-trip migrations (reversibility)
  • Error cases with detailed error messages
  • Composition and chaining
  • Property-based tests (reversibility, composition, identity, idempotence)

All tests pass successfully on Scala 2.13 (1887 tests) and Scala 3.3 (1952 tests) across JVM, JS, and Native platforms.

Documentation

Complete documentation following the zio-blocks style:

  • Overview and motivation
  • Core concepts and architecture
  • Complete API reference for all action types
  • Real-world examples (user evolution, API responses, database migrations, event sourcing, configuration)
  • Advanced patterns (bidirectional migrations, conditional migrations, migration chains, versioned data storage)
  • Testing strategies (unit tests, property-based tests, integration tests)
  • Best practices and guidelines
  • Performance considerations
  • Common pitfalls and solutions
  • Comparison with other approaches

Example Usage

Selector-Based API (Recommended)

// Define schemas
case class PersonV1(name: String, age: Int)
case class PersonV2(fullName: String, age: Int, country: String)
// Create migration with type-safe selectors
val migration = MigrationBuilder(
Schema.derived[PersonV1],
Schema.derived[PersonV2]
)
.renameField(_.name, _.fullName) // Type-safe field selection
.addField(_.country, "USA") // IDE autocomplete works!
.build // Compile-time validation
// Apply migration
val v1 = PersonV1("Alice", 30)
val v2: PersonV2 = migration.migrate(v1).toOption.get
// PersonV2("Alice", 30, "USA")

String-Based API (Alternative)

// Same migration using string-based API
val migration = MigrationBuilder(
Schema.derived[PersonV1],
Schema.derived[PersonV2]
)
.renameFieldByName("name", "fullName")
.addFieldWithDefault("country", "USA")
.build
// Apply migration
val person = PersonV1("Alice", 30)
val result = migration.apply(person)
// Right(PersonV2("Alice", 30, "USA"))
// Reverse migration
val reversed = migration.reverse.apply(result.toOption.get)
// Right(PersonV1("Alice", 30))

Addresses Issue

Closes #519

Claim

Total prize pool $4,000
Total paid $0
Status Pending
Submitted January 22, 2026
Last updated January 22, 2026

Contributors

BI

bikramadhikari001

@bikramadhikari001

100%

Sponsors

ZI

ZIO

@ZIO

$4,000