Schema Migration System for ZIO Blocks

Closes #519


Summary

Implements a complete, pure algebraic schema migration system as specified in #519. Migrations are represented entirely as serializable data — no functions, closures, or reflection anywhere in the action ADT.


Architecture

Two-layer design mirroring the existing DynamicPatch / Patch[A] pattern:

DynamicMigration — untyped, pure data, fully serializable

  • actions: Vector[MigrationAction] applied left-to-right
  • apply(DynamicValue): Either[MigrationError, DynamicValue]
  • reverse: DynamicMigration — structural inverse of all actions in reverse order
  • ++ composition, isEmpty, identity

Migration[A, B] — typed wrapper

  • Encodes A → DynamicValue via sourceSchema
  • Applies DynamicMigration
  • Decodes DynamicValue → B via targetSchema
  • reverse: Migration[B, A], ++[C], andThen[C]

MigrationAction ADT (14 cases)

All actions carry at: DynamicOptic for precise path addressing. Every action implements reverse: MigrationAction.

Action Reverse
AddField DropField
DropField AddField
Rename Rename (swapped)
TransformValue Identity (best-effort)
Mandate Optionalize
Optionalize Mandate
Join Split
Split Join
ChangeType ChangeType (inverse conversion)
RenameCase RenameCase (swapped)
TransformCase TransformCase (reversed actions)
TransformElements Identity (best-effort)
TransformKeys Identity (best-effort)
TransformValues Identity (best-effort)
NestedMigration NestedMigration (reversed)

Non-bijective reverse semantics are documented with ScalaDoc on each case.


MigrationExpr — Pure Data Expression ADT

MigrationExpr is a separate pure-data ADT (distinct from the existing query SchemaExpr) used for all value-level transformations:

  • Identity, Literal(DynamicValue), FieldAccess(DynamicOptic)
  • Convert(expr, PrimitiveConversion) — 12 primitive coercions
  • Concat(exprs, separator), Compose(first, second), DefaultValue

All cases are serializable data. No lambdas or closures.


Selector Macro API

// Scala 3
inline def select[A](inline f: A => Any): DynamicOptic
// Scala 2
def select[A](f: A => Any): DynamicOptic // macro

Converts _.field.nested selectors into DynamicOptic at compile time. Supports field access, .when[T] case selection, .each traversal. Emits compile error on non-selector expressions.


MigrationBuilder DSL

val migration = Migration.newBuilder[PersonV0, Person]
.renameField(select(_.firstName), select(_.fullName))
.addField(select(_.age), MigrationExpr.Literal(DynamicValue.Primitive(0)))
.inField(select(_.address), select(_.address), addressMigration)
.build // compile error if any field in Person is unhandled

.build validates at compile time that every field in the target type is accounted for. Missing fields produce a compiler error naming the unhandled fields:

error: Migration.build: unhandled target fields in Person: age

.buildPartial skips validation for partial/incremental migrations.


Nested Migrations

inField composes a sub-migration over a nested record field. The sub-migration’s actions are encoded as a NestedMigration action, applied recursively by DynamicMigration.apply.

val addressMigration = Migration.newBuilder[AddressV0, AddressV1]
.renameField(select(_.street), select(_.streetName))
.renameField(select(_.zip), select(_.postalCode))
.addField(select(_.country), MigrationExpr.Literal(DynamicValue.Primitive("US")))
.buildPartial
val personMigration = Migration.newBuilder[PersonV0, Person]
.renameField(select(_.name), select(_.name))
.inField(select(_.address), select(_.address), addressMigration)
.build

Schema Instances

All new types (MigrationError, MigrationExpr, MigrationAction, DynamicMigration) have hand-written Schema instances following the DynamicPatch manual derivation style. No Schema.derived anywhere.


Algebraic Laws

Verified with ZIO Test property-based tests in MigrationLawSpec:

  • Identity: Migration.identity[A].apply(a) == Right(a)
  • Associativity: (m1 ++ m2) ++ m3 and m1 ++ (m2 ++ m3) produce identical results
  • Structural reverse: m.reverse.reverse.actions matches m.actions
  • Round-trip: for bijective operations, m.apply(a).flatMap(m.reverse.apply) == Right(a)

Test Coverage

Spec What it covers
DynamicMigrationSpec Each of the 14 action types, error paths with DynamicOptic
MigrationLawSpec Identity, associativity, reverse laws (property-based)
MigrationSpec Typed Migration[A,B], nested via inField, enum rename, composition
MigrationMacroSpec Selector macros, .build success, .build compile-fail with field name

Files Added

schema/shared/src/main/scala/zio/blocks/schema/migration/
MigrationError.scala
MigrationExpr.scala
MigrationAction.scala
DynamicMigration.scala
Migration.scala
MigrationBuilder.scala
schema/shared/src/main/scala-2/zio/blocks/schema/migration/
MigrationMacros.scala
schema/shared/src/main/scala-3/zio/blocks/schema/migration/
MigrationMacros.scala
schema/shared/src/test/scala/zio/blocks/schema/migration/
DynamicMigrationSpec.scala
MigrationLawSpec.scala
MigrationSpec.scala
schema/shared/src/test/scala-3/zio/blocks/schema/migration/
MigrationMacroSpec.scala

No existing files were modified.

/claim #519

Claim

Total prize pool $8,000
Total paid $0
Status Pending
Submitted March 27, 2026
Last updated March 27, 2026

Contributors

HA

Harshada Gawas

@harshadagawas95-gmail-com

100%

Sponsors

MA

marianaguzmanguerrero16-dev

@marianaguzmanguerrero16-dev

$4,000
ZI

ZIO

@ZIO

$4,000