Event Type Migrations
Event type migrations enable you to evolve your event schemas over time while maintaining compatibility with existing events. When an event type changes, you can define upcasters and downcasters that automatically transform events between different generations.
Why Migrations?
Section titled “Why Migrations?”In evolving systems, event schemas naturally change:
- Properties are added or removed
- Properties are renamed
- Complex properties are split or combined
Chronicle’s migration system allows you to:
- Define declarative transformation rules
- Automatically store all generations of an event when appending
- Read events in any generation format
Defining Migrations
Section titled “Defining Migrations”To define a migration, extend the EventTypeMigration<TUpgrade, TPrevious> base class (it handles generation extraction and validation for you) and override Upcast/Downcast:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType("author-registered", generation: 2)]public record MigrationsAuthorRegistered(string FirstName, string LastName);
[EventTypeGenerationFor<MigrationsAuthorRegistered>(1)]public record MigrationsAuthorRegisteredV1(string Name);
public class MigrationsAuthorRegisteredMigration : EventTypeMigration<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1> builder) => builder.Properties(pb => pb .Split(m => m.FirstName, e => e.Name, PropertySeparator.Space, SplitPartIndex.First) .Split(m => m.LastName, e => e.Name, PropertySeparator.Space, SplitPartIndex.Second));
public override void Downcast(IEventMigrationBuilder<MigrationsAuthorRegisteredV1, MigrationsAuthorRegistered> builder) => builder.Properties(pb => pb .Combine(m => m.Name, PropertySeparator.Space, e => e.FirstName, e => e.LastName));}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.migrations.EventTypeMigrationimport io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder
@EventType(id = "author-registered", generation = 2)data class MigrationsAuthorRegistered(val firstName: String, val lastName: String)
@EventType(id = "author-registered", generation = 1)data class MigrationsAuthorRegisteredV1(val name: String)
class MigrationsAuthorRegisteredMigration : EventTypeMigration<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1>( MigrationsAuthorRegistered::class, MigrationsAuthorRegisteredV1::class ) { override fun upcast(builder: EventTypeMigrationBuilder<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1>) { builder .split(MigrationsAuthorRegistered::firstName, MigrationsAuthorRegisteredV1::name, " ", 0) .split(MigrationsAuthorRegistered::lastName, MigrationsAuthorRegisteredV1::name, " ", 1) }
override fun downcast(builder: EventTypeMigrationBuilder<MigrationsAuthorRegisteredV1, MigrationsAuthorRegistered>) { builder.combine(MigrationsAuthorRegisteredV1::name, " ", MigrationsAuthorRegistered::firstName, MigrationsAuthorRegistered::lastName) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.events.migrations.EventTypeMigration;import io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder;
@EventType(id = "author-registered", generation = 2)record MigrationsAuthorRegistered(String firstName, String lastName) {}
@EventType(id = "author-registered", generation = 1)record MigrationsAuthorRegisteredV1(String name) {}
class MigrationsAuthorRegisteredMigration extends EventTypeMigration<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1> { MigrationsAuthorRegisteredMigration() { super(MigrationsAuthorRegistered.class, MigrationsAuthorRegisteredV1.class); }
@Override public void upcast(EventTypeMigrationBuilder<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1> builder) { builder .split("firstName", "name", " ", 0) .split("lastName", "name", " ", 1); }
@Override public void downcast(EventTypeMigrationBuilder<MigrationsAuthorRegisteredV1, MigrationsAuthorRegistered> builder) { builder.combine("name", " ", "firstName", "lastName"); }}defmodule MyApp.Events.MigrationsAuthorRegisteredV1 do use Chronicle.Events.EventType, id: "author-registered", generation: 1
defstruct [:name]end
defmodule MyApp.Events.MigrationsAuthorRegistered do use Chronicle.Events.EventType, id: "author-registered", generation: 2
defstruct [:first_name, :last_name]end
defmodule MyApp.Migrations.MigrationsAuthorRegisteredMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsAuthorRegisteredV1, generation: 1}, to: {MyApp.Events.MigrationsAuthorRegistered, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder |> MigrationBuilder.split_property(:name, :first_name, " ", 0) |> MigrationBuilder.split_property(:name, :last_name, " ", 1) end
@impl true def downcast(builder) do builder |> MigrationBuilder.combine_properties([:first_name, :last_name], :name, " ") endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType('author-registered', 1)class MigrationsAuthorRegisteredV1 { @field(String) readonly name: string;
constructor(name: string) { this.name = name; }}
@eventType('author-registered', 2)class MigrationsAuthorRegistered { @field(String) readonly firstName: string; @field(String) readonly lastName: string;
constructor(firstName: string, lastName: string) { this.firstName = firstName; this.lastName = lastName; }}
@eventTypeMigration(MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1)class MigrationsAuthorRegisteredMigration implements IEventTypeMigration<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1> { upcast(builder: IEventMigrationBuilder<MigrationsAuthorRegistered, MigrationsAuthorRegisteredV1>): void { builder.properties(pb => pb .split('firstName', 'name', ' ', 0) .split('lastName', 'name', ' ', 1)); }
downcast(builder: IEventMigrationBuilder<MigrationsAuthorRegisteredV1, MigrationsAuthorRegistered>): void { builder.properties(pb => pb .combine('name', ' ', 'firstName', 'lastName')); }}The previous generation, MigrationsAuthorRegisteredV1, carries [EventTypeGenerationFor<MigrationsAuthorRegistered>(1)] instead of its own [EventType]. The event type id it resolves to comes straight from MigrationsAuthorRegistered’s own [EventType] — it is never independently typed on the previous-generation record, so the two generations cannot drift apart by a hand-typed, mismatched id.
Migration Operations
Section titled “Migration Operations”The migration builder supports the following operations:
Splits a source property into parts using a separator:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType("person-registered", generation: 2)]public record MigrationsSplitPersonRegistered(string FirstName, string LastName);
[EventTypeGenerationFor<MigrationsSplitPersonRegistered>(1)]public record MigrationsSplitPersonRegisteredV1(string FullName);
public class MigrationsSplitPersonRegisteredMigration : EventTypeMigration<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1> builder) => builder.Properties(pb => pb .Split(m => m.FirstName, e => e.FullName, PropertySeparator.Space, SplitPartIndex.First) // Gets first part .Split(m => m.LastName, e => e.FullName, PropertySeparator.Space, SplitPartIndex.Second)); // Gets second part
public override void Downcast(IEventMigrationBuilder<MigrationsSplitPersonRegisteredV1, MigrationsSplitPersonRegistered> builder) => builder.Properties(pb => pb .Combine(m => m.FullName, PropertySeparator.Space, e => e.FirstName, e => e.LastName));}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.migrations.EventTypeMigrationimport io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder
@EventType(id = "person-registered", generation = 2)data class MigrationsSplitPersonRegistered(val firstName: String, val lastName: String)
@EventType(id = "person-registered", generation = 1)data class MigrationsSplitPersonRegisteredV1(val fullName: String)
class MigrationsSplitPersonRegisteredMigration : EventTypeMigration<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1>( MigrationsSplitPersonRegistered::class, MigrationsSplitPersonRegisteredV1::class ) { override fun upcast(builder: EventTypeMigrationBuilder<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1>) { builder .split(MigrationsSplitPersonRegistered::firstName, MigrationsSplitPersonRegisteredV1::fullName, " ", 0) // Gets first part .split(MigrationsSplitPersonRegistered::lastName, MigrationsSplitPersonRegisteredV1::fullName, " ", 1) // Gets second part }
override fun downcast(builder: EventTypeMigrationBuilder<MigrationsSplitPersonRegisteredV1, MigrationsSplitPersonRegistered>) { builder.combine(MigrationsSplitPersonRegisteredV1::fullName, " ", MigrationsSplitPersonRegistered::firstName, MigrationsSplitPersonRegistered::lastName) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.events.migrations.EventTypeMigration;import io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder;
@EventType(id = "person-registered", generation = 2)record MigrationsSplitPersonRegistered(String firstName, String lastName) {}
@EventType(id = "person-registered", generation = 1)record MigrationsSplitPersonRegisteredV1(String fullName) {}
class MigrationsSplitPersonRegisteredMigration extends EventTypeMigration<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1> { MigrationsSplitPersonRegisteredMigration() { super(MigrationsSplitPersonRegistered.class, MigrationsSplitPersonRegisteredV1.class); }
@Override public void upcast( EventTypeMigrationBuilder<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1> builder) { builder .split("firstName", "fullName", " ", 0) // Gets first part .split("lastName", "fullName", " ", 1); // Gets second part }
@Override public void downcast( EventTypeMigrationBuilder<MigrationsSplitPersonRegisteredV1, MigrationsSplitPersonRegistered> builder) { builder.combine("fullName", " ", "firstName", "lastName"); }}defmodule MyApp.Events.MigrationsSplitPersonRegisteredV1 do use Chronicle.Events.EventType, id: "person-registered", generation: 1
defstruct [:full_name]end
defmodule MyApp.Events.MigrationsSplitPersonRegistered do use Chronicle.Events.EventType, id: "person-registered", generation: 2
defstruct [:first_name, :last_name]end
defmodule MyApp.Migrations.MigrationsSplitPersonRegisteredMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsSplitPersonRegisteredV1, generation: 1}, to: {MyApp.Events.MigrationsSplitPersonRegistered, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder # Gets first part |> MigrationBuilder.split_property(:full_name, :first_name, " ", 0) # Gets second part |> MigrationBuilder.split_property(:full_name, :last_name, " ", 1) end
@impl true def downcast(builder) do builder |> MigrationBuilder.combine_properties([:first_name, :last_name], :full_name, " ") endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType('person-registered', 1)class MigrationsSplitPersonRegisteredV1 { @field(String) readonly fullName: string;
constructor(fullName: string) { this.fullName = fullName; }}
@eventType('person-registered', 2)class MigrationsSplitPersonRegistered { @field(String) readonly firstName: string; @field(String) readonly lastName: string;
constructor(firstName: string, lastName: string) { this.firstName = firstName; this.lastName = lastName; }}
@eventTypeMigration(MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1)class MigrationsSplitPersonRegisteredMigration implements IEventTypeMigration<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1> { upcast(builder: IEventMigrationBuilder<MigrationsSplitPersonRegistered, MigrationsSplitPersonRegisteredV1>): void { builder.properties(pb => pb .split('firstName', 'fullName', ' ', 0) // Gets first part .split('lastName', 'fullName', ' ', 1)); // Gets second part }
downcast(builder: IEventMigrationBuilder<MigrationsSplitPersonRegisteredV1, MigrationsSplitPersonRegistered>): void { builder.properties(pb => pb .combine('fullName', ' ', 'firstName', 'lastName')); }}Combine
Section titled “Combine”Combines multiple source properties into a single value using a separator:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType("shipping-address-recorded", generation: 2)]public record MigrationsCombineShippingAddressRecorded(string FormattedAddress);
[EventTypeGenerationFor<MigrationsCombineShippingAddressRecorded>(1)]public record MigrationsCombineShippingAddressRecordedV1(string Street, string City);
public class MigrationsCombineShippingAddressRecordedMigration : EventTypeMigration<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1> builder) => builder.Properties(pb => pb .Combine(m => m.FormattedAddress, PropertySeparator.Space, e => e.Street, e => e.City)); // Joins with space separator
public override void Downcast(IEventMigrationBuilder<MigrationsCombineShippingAddressRecordedV1, MigrationsCombineShippingAddressRecorded> builder) => builder.Properties(pb => pb .Split(m => m.Street, e => e.FormattedAddress, PropertySeparator.Space, SplitPartIndex.First) .Split(m => m.City, e => e.FormattedAddress, PropertySeparator.Space, SplitPartIndex.Second));}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.migrations.EventTypeMigrationimport io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder
@EventType(id = "shipping-address-recorded", generation = 2)data class MigrationsCombineShippingAddressRecorded(val formattedAddress: String)
@EventType(id = "shipping-address-recorded", generation = 1)data class MigrationsCombineShippingAddressRecordedV1(val street: String, val city: String)
class MigrationsCombineShippingAddressRecordedMigration : EventTypeMigration<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1>( MigrationsCombineShippingAddressRecorded::class, MigrationsCombineShippingAddressRecordedV1::class ) { override fun upcast(builder: EventTypeMigrationBuilder<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1>) { // Joins with space separator builder.combine( MigrationsCombineShippingAddressRecorded::formattedAddress, " ", MigrationsCombineShippingAddressRecordedV1::street, MigrationsCombineShippingAddressRecordedV1::city ) }
override fun downcast(builder: EventTypeMigrationBuilder<MigrationsCombineShippingAddressRecordedV1, MigrationsCombineShippingAddressRecorded>) { builder .split(MigrationsCombineShippingAddressRecordedV1::street, MigrationsCombineShippingAddressRecorded::formattedAddress, " ", 0) .split(MigrationsCombineShippingAddressRecordedV1::city, MigrationsCombineShippingAddressRecorded::formattedAddress, " ", 1) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.events.migrations.EventTypeMigration;import io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder;
@EventType(id = "shipping-address-recorded", generation = 2)record MigrationsCombineShippingAddressRecorded(String formattedAddress) {}
@EventType(id = "shipping-address-recorded", generation = 1)record MigrationsCombineShippingAddressRecordedV1(String street, String city) {}
class MigrationsCombineShippingAddressRecordedMigration extends EventTypeMigration<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1> { MigrationsCombineShippingAddressRecordedMigration() { super(MigrationsCombineShippingAddressRecorded.class, MigrationsCombineShippingAddressRecordedV1.class); }
@Override public void upcast(EventTypeMigrationBuilder<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1> builder) { // Joins with space separator builder.combine("formattedAddress", " ", "street", "city"); }
@Override public void downcast(EventTypeMigrationBuilder<MigrationsCombineShippingAddressRecordedV1, MigrationsCombineShippingAddressRecorded> builder) { builder .split("street", "formattedAddress", " ", 0) .split("city", "formattedAddress", " ", 1); }}defmodule MyApp.Events.MigrationsCombineShippingAddressRecordedV1 do use Chronicle.Events.EventType, id: "shipping-address-recorded", generation: 1
defstruct [:street, :city]end
defmodule MyApp.Events.MigrationsCombineShippingAddressRecorded do use Chronicle.Events.EventType, id: "shipping-address-recorded", generation: 2
defstruct [:formatted_address]end
defmodule MyApp.Migrations.MigrationsCombineShippingAddressRecordedMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsCombineShippingAddressRecordedV1, generation: 1}, to: {MyApp.Events.MigrationsCombineShippingAddressRecorded, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder # Joins with space separator |> MigrationBuilder.combine_properties([:street, :city], :formatted_address, " ") end
@impl true def downcast(builder) do builder |> MigrationBuilder.split_property(:formatted_address, :street, " ", 0) |> MigrationBuilder.split_property(:formatted_address, :city, " ", 1) endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType('shipping-address-recorded', 1)class MigrationsCombineShippingAddressRecordedV1 { @field(String) readonly street: string; @field(String) readonly city: string;
constructor(street: string, city: string) { this.street = street; this.city = city; }}
@eventType('shipping-address-recorded', 2)class MigrationsCombineShippingAddressRecorded { @field(String) readonly formattedAddress: string;
constructor(formattedAddress: string) { this.formattedAddress = formattedAddress; }}
@eventTypeMigration(MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1)class MigrationsCombineShippingAddressRecordedMigration implements IEventTypeMigration<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1> { upcast(builder: IEventMigrationBuilder<MigrationsCombineShippingAddressRecorded, MigrationsCombineShippingAddressRecordedV1>): void { builder.properties(pb => pb .combine('formattedAddress', ' ', 'street', 'city')); // Joins with space separator }
downcast(builder: IEventMigrationBuilder<MigrationsCombineShippingAddressRecordedV1, MigrationsCombineShippingAddressRecorded>): void { builder.properties(pb => pb .split('street', 'formattedAddress', ' ', 0) .split('city', 'formattedAddress', ' ', 1)); }}Rename
Section titled “Rename”Renames a property from a previous name using RenamedFrom:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType("payment-processed", generation: 2)]public record MigrationsRenamePaymentProcessed(decimal Amount);
[EventTypeGenerationFor<MigrationsRenamePaymentProcessed>(1)]public record MigrationsRenamePaymentProcessedV1(decimal OldAmount);
public class MigrationsRenamePaymentProcessedMigration : EventTypeMigration<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1> builder) => builder.Properties(pb => pb .RenamedFrom(m => m.Amount, e => e.OldAmount));
public override void Downcast(IEventMigrationBuilder<MigrationsRenamePaymentProcessedV1, MigrationsRenamePaymentProcessed> builder) => builder.Properties(pb => pb .RenamedFrom(m => m.OldAmount, e => e.Amount));}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.migrations.EventTypeMigrationimport io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder
@EventType(id = "payment-processed", generation = 2)data class MigrationsRenamePaymentProcessed(val amount: Double)
@EventType(id = "payment-processed", generation = 1)data class MigrationsRenamePaymentProcessedV1(val oldAmount: Double)
class MigrationsRenamePaymentProcessedMigration : EventTypeMigration<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1>( MigrationsRenamePaymentProcessed::class, MigrationsRenamePaymentProcessedV1::class ) { override fun upcast(builder: EventTypeMigrationBuilder<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1>) { builder.renamedFrom(MigrationsRenamePaymentProcessed::amount, MigrationsRenamePaymentProcessedV1::oldAmount) }
override fun downcast(builder: EventTypeMigrationBuilder<MigrationsRenamePaymentProcessedV1, MigrationsRenamePaymentProcessed>) { builder.renamedFrom(MigrationsRenamePaymentProcessedV1::oldAmount, MigrationsRenamePaymentProcessed::amount) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.events.migrations.EventTypeMigration;import io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder;
@EventType(id = "payment-processed", generation = 2)record MigrationsRenamePaymentProcessed(double amount) {}
@EventType(id = "payment-processed", generation = 1)record MigrationsRenamePaymentProcessedV1(double oldAmount) {}
class MigrationsRenamePaymentProcessedMigration extends EventTypeMigration<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1> { MigrationsRenamePaymentProcessedMigration() { super(MigrationsRenamePaymentProcessed.class, MigrationsRenamePaymentProcessedV1.class); }
@Override public void upcast( EventTypeMigrationBuilder<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1> builder) { builder.renamedFrom("amount", "oldAmount"); }
@Override public void downcast( EventTypeMigrationBuilder<MigrationsRenamePaymentProcessedV1, MigrationsRenamePaymentProcessed> builder) { builder.renamedFrom("oldAmount", "amount"); }}defmodule MyApp.Events.MigrationsRenamePaymentProcessedV1 do use Chronicle.Events.EventType, id: "payment-processed", generation: 1
defstruct [:old_amount]end
defmodule MyApp.Events.MigrationsRenamePaymentProcessed do use Chronicle.Events.EventType, id: "payment-processed", generation: 2
defstruct [:amount]end
defmodule MyApp.Migrations.MigrationsRenamePaymentProcessedMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsRenamePaymentProcessedV1, generation: 1}, to: {MyApp.Events.MigrationsRenamePaymentProcessed, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder |> MigrationBuilder.rename_property(:old_amount, :amount) end
@impl true def downcast(builder) do builder |> MigrationBuilder.rename_property(:amount, :old_amount) endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType('payment-processed', 1)class MigrationsRenamePaymentProcessedV1 { @field(Number) readonly oldAmount: number;
constructor(oldAmount: number) { this.oldAmount = oldAmount; }}
@eventType('payment-processed', 2)class MigrationsRenamePaymentProcessed { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
@eventTypeMigration(MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1)class MigrationsRenamePaymentProcessedMigration implements IEventTypeMigration<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1> { upcast(builder: IEventMigrationBuilder<MigrationsRenamePaymentProcessed, MigrationsRenamePaymentProcessedV1>): void { builder.properties(pb => pb .renamedFrom('amount', 'oldAmount')); }
downcast(builder: IEventMigrationBuilder<MigrationsRenamePaymentProcessedV1, MigrationsRenamePaymentProcessed>): void { builder.properties(pb => pb .renamedFrom('oldAmount', 'amount')); }}Default Value
Section titled “Default Value”Sets a default value for a new property:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
[EventType("order-shipped", generation: 2)]public record MigrationsDefaultValueOrderShipped(string TrackingNumber, int RetryCount, string Description);
[EventTypeGenerationFor<MigrationsDefaultValueOrderShipped>(1)]public record MigrationsDefaultValueOrderShippedV1(string TrackingNumber);
public class MigrationsDefaultValueOrderShippedMigration : EventTypeMigration<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1> builder) => builder.Properties(pb => pb .DefaultValue(m => m.RetryCount, 42) .DefaultValue(m => m.Description, "default string"));
public override void Downcast(IEventMigrationBuilder<MigrationsDefaultValueOrderShippedV1, MigrationsDefaultValueOrderShipped> builder) { // RetryCount and Description did not exist in generation 1 — nothing to map back }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.migrations.EventTypeMigrationimport io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder
@EventType(id = "order-shipped", generation = 2)data class MigrationsDefaultValueOrderShipped(val trackingNumber: String, val retryCount: Int, val description: String)
@EventType(id = "order-shipped", generation = 1)data class MigrationsDefaultValueOrderShippedV1(val trackingNumber: String)
class MigrationsDefaultValueOrderShippedMigration : EventTypeMigration<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1>( MigrationsDefaultValueOrderShipped::class, MigrationsDefaultValueOrderShippedV1::class ) { override fun upcast(builder: EventTypeMigrationBuilder<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1>) { builder .defaultValue(MigrationsDefaultValueOrderShipped::retryCount, 42) .defaultValue(MigrationsDefaultValueOrderShipped::description, "default string") }
override fun downcast(builder: EventTypeMigrationBuilder<MigrationsDefaultValueOrderShippedV1, MigrationsDefaultValueOrderShipped>) { // retryCount and description did not exist in generation 1 — nothing to map back }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.events.migrations.EventTypeMigration;import io.cratis.chronicle.events.migrations.EventTypeMigrationBuilder;
@EventType(id = "order-shipped", generation = 2)record MigrationsDefaultValueOrderShipped(String trackingNumber, int retryCount, String description) {}
@EventType(id = "order-shipped", generation = 1)record MigrationsDefaultValueOrderShippedV1(String trackingNumber) {}
class MigrationsDefaultValueOrderShippedMigration extends EventTypeMigration<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1> { MigrationsDefaultValueOrderShippedMigration() { super(MigrationsDefaultValueOrderShipped.class, MigrationsDefaultValueOrderShippedV1.class); }
@Override public void upcast( EventTypeMigrationBuilder<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1> builder) { builder .defaultValue("retryCount", 42) .defaultValue("description", "default string"); }
@Override public void downcast( EventTypeMigrationBuilder<MigrationsDefaultValueOrderShippedV1, MigrationsDefaultValueOrderShipped> builder) { // retryCount and description did not exist in generation 1 — nothing to map back }}defmodule MyApp.Events.MigrationsDefaultValueOrderShippedV1 do use Chronicle.Events.EventType, id: "order-shipped", generation: 1
defstruct [:tracking_number]end
defmodule MyApp.Events.MigrationsDefaultValueOrderShipped do use Chronicle.Events.EventType, id: "order-shipped", generation: 2
defstruct [:tracking_number, :retry_count, :description]end
defmodule MyApp.Migrations.MigrationsDefaultValueOrderShippedMigration do use Chronicle.Events.Migration, from: {MyApp.Events.MigrationsDefaultValueOrderShippedV1, generation: 1}, to: {MyApp.Events.MigrationsDefaultValueOrderShipped, generation: 2}
alias Chronicle.Events.MigrationBuilder
@impl true def upcast(builder) do builder |> MigrationBuilder.default_value(:retry_count, 42) |> MigrationBuilder.default_value(:description, "default string") end
@impl true def downcast(builder) do # retry_count and description did not exist in generation 1 — nothing to map back builder endendimport { eventType, eventTypeMigration, IEventTypeMigration, IEventMigrationBuilder } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType('order-shipped', 1)class MigrationsDefaultValueOrderShippedV1 { @field(String) readonly trackingNumber: string;
constructor(trackingNumber: string) { this.trackingNumber = trackingNumber; }}
@eventType('order-shipped', 2)class MigrationsDefaultValueOrderShipped { @field(String) readonly trackingNumber: string; @field(Number) readonly retryCount: number; @field(String) readonly description: string;
constructor(trackingNumber: string, retryCount: number, description: string) { this.trackingNumber = trackingNumber; this.retryCount = retryCount; this.description = description; }}
@eventTypeMigration(MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1)class MigrationsDefaultValueOrderShippedMigration implements IEventTypeMigration<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1> { upcast(builder: IEventMigrationBuilder<MigrationsDefaultValueOrderShipped, MigrationsDefaultValueOrderShippedV1>): void { builder.properties(pb => pb .defaultValue('retryCount', 42) .defaultValue('description', 'default string')); }
downcast(_builder: IEventMigrationBuilder<MigrationsDefaultValueOrderShippedV1, MigrationsDefaultValueOrderShipped>): void { // retryCount and description did not exist in generation 1 — nothing to map back }}Value map
Section titled “Value map”The operations above move values between properties. A value map changes the values themselves — it states which value in one generation is which value in the other. This is what you reach for when an enum is renumbered, or a status code takes on a new set of values, and the old numbers now mean something the new generation spells differently.
Override MapValues on the migration rather than declaring it inside Upcast or Downcast:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Migrations;
public enum MigrationsSubscriptionStateV1{ Unknown = 0, Active = 1, Cancelled = 2}
public enum MigrationsSubscriptionState{ Unspecified = 100, Running = 101, Stopped = 102}
[EventType("subscription-state-changed", generation: 2)]public record MigrationsSubscriptionStateChanged(MigrationsSubscriptionState State);
[EventTypeGenerationFor<MigrationsSubscriptionStateChanged>(1)]public record MigrationsSubscriptionStateChangedV1(MigrationsSubscriptionStateV1 State);
public class MigrationsSubscriptionStateChangedMigration : EventTypeMigration<MigrationsSubscriptionStateChanged, MigrationsSubscriptionStateChangedV1>{ public override void Upcast(IEventMigrationBuilder<MigrationsSubscriptionStateChanged, MigrationsSubscriptionStateChangedV1> builder) { // The value map covers State in both directions }
public override void Downcast(IEventMigrationBuilder<MigrationsSubscriptionStateChangedV1, MigrationsSubscriptionStateChanged> builder) { // The value map covers State in both directions }
public override void MapValues(IEventValueMapBuilder<MigrationsSubscriptionStateChanged, MigrationsSubscriptionStateChangedV1> builder) => builder.For(current => current.State, previous => previous.State, map => map .Map(MigrationsSubscriptionStateV1.Unknown, MigrationsSubscriptionState.Unspecified) .Map(MigrationsSubscriptionStateV1.Active, MigrationsSubscriptionState.Running) .Map(MigrationsSubscriptionStateV1.Cancelled, MigrationsSubscriptionState.Stopped));}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.A map declared this way is applied in both directions: forward when upcasting, inverted when downcasting. The pair of values is one fact about the two generations, so you state it once and the two directions cannot drift apart.
Three things are worth knowing:
- Values the map does not mention are carried across unchanged. A map says what changed meaning; it is not an exhaustive listing of an enum that is free to grow.
- Two values collapsing onto one have no single inverse, so the first pair declared for a target value is the one that represents it going back.
- The map does not have to stay on the same property. The two property expressions may name different properties, in which case the map also carries the value across — covering a rename and a re-valuing in one statement.
MapValues is applied before Upcast and Downcast run, so a direction that states its own transformation for the same property keeps it.
How Migrations Work
Section titled “How Migrations Work”When an event is appended to the event store:
- Chronicle identifies the event’s current generation
- The migration system retrieves all registered migrations for the event type
- Upcasting: If there are higher generations, the event is transformed upward (1→2→3)
- Downcasting: If there are lower generations, the event is transformed downward (3→2→1)
- All generations are stored in the event sequence
This ensures that:
- Older consumers can still read events in their expected format
- Newer consumers can read events with the latest schema
- The generation an event was appended as is stored as written. Current backfills only add missing generations; they do not overwrite stored ones. Earlier backfills may already have overwritten content, and SQL revisions still replace content in place. The other generations hold only what the migrations produce: the example above splits a name on spaces, so for “Mary Ann Smith” the upcast generation 2 keeps “Mary” and “Ann” and drops “Smith”. Write migrations that keep every part a consumer needs.
Opt in to pinned observer delivery
Section titled “Opt in to pinned observer delivery”Client reactors and reducers can request the generation their handlers subscribe to, rather than
receiving the appended generation live and the highest stored generation on replay. In the .NET
client, set ChronicleOptions.EventGenerationDelivery to EventGenerationDelivery.Pinned before
connecting; see the .NET client option.
The default remains Compatibility in this major version. Upgrade every kernel silo before opting in.
For pinned observers, the kernel selects the subscribed generation’s content and hash and releases
protected values with that generation’s schema on live, catch-up, replay, and partition-retry delivery.
EventContext.EventType.Generation is the delivered generation; EventContext.AppendedGeneration
records the original append generation, or is null for older events where it is unknown.
If the pinned generation has not been stored yet, the kernel releases the known appended generation
(or the highest stored generation for legacy events) and migrates it for that delivery only. It does
not persist this result or cache plaintext. Such a delivery has EventHash.NotSet, because it has
no durable stored hash. Erased personal data stays erased through the migration.
A revised event is delivered from its latest revision, migrated to the pin when necessary. Its hash
is the revision’s stored hash only when the revision already matches the pin; otherwise it is
EventHash.NotSet. SQL revisions made before this release cannot be distinguished from original
content: those rows keep the existing generation-selection behavior until revised again.
A reactor or reducer can pin only one generation per event type. Registration rejects unregistered generations and handlers for multiple generations of the same type. If a pinned generation disappears from the registered definition later, delivery fails explicitly rather than substituting a schema. For reactors and reducers, changing a pin or delivery mode does not itself trigger a replay; request one when you need to rebuild prior state.
The .NET option also opts typed materialized projections into pinned delivery. Each From, Join, child,
key, parent-key, and removal clause uses the generation it names, including events read from storage
to resolve joins or parent keys. A pinned projection can name only one generation per event type
across all its clauses, including children and nested objects; registration rejects conflicting pins,
just as it does for reactors and reducers. Different event types can pin different generations.
Live, catch-up, and replay use the same generation-selection and release boundary.
For a PDL definition saved with GenerationDelivery = Pinned, a bare from PersonRegistered resolves
to the latest registered generation when you save it. That resolved generation is persisted as the
pin; registering a newer generation later does not move it. The same rule applies to join and removal
references, including those in children and nested objects. Saving the declaration again resolves its
names against the generations registered at that time.
PDL, which is owned by Screenplay, does not have generation-qualified event references. Use the .NET projection builders or model-bound attributes with the CLR event type for the generation you want when a clause must pin an explicit generation. Compatibility definitions keep the existing bare-name and identifier-only dispatch behavior.
Webhooks opt in separately, per registration, with the .NET builder’s
WithGenerationDelivery(EventGenerationDelivery.Pinned) or Core’s GenerationDelivery property.
Their subscribed event types supply exact generation pins, which remain fixed until you deliberately
update the registration. The HTTP payload envelope is unchanged, including GenerationalContent;
only the selected content, delivered generation, and hash follow the pin. See
webhook generation delivery.
Event-store subscriptions also opt in separately with
WithGenerationDelivery(EventGenerationDelivery.Pinned). The target application’s declared event types
supply exact pins for the source outbox observer. The kernel delivers those generations on live,
catch-up, replay, and retry, then appends the delivered generation to the target inbox. Both stores
must have that exact generation registered; a missing target generation fails forwarding without
appending or acknowledging the failing event. Pins survive reconnects and identical registrations;
only a deliberate registration update changes them. See
pinned subscription delivery.
GenerationalContent remains on delivered events for compatibility in this major version. The global
client option does not change immediate/passive or wildcard (all) projections, reads, webhooks,
captures, or event-store subscriptions.
Protected values
Section titled “Protected values”Adding protection metadata within an existing generation affects future appends only. Creating a new generation also leaves the original generation’s stored content untouched. Neither approach encrypts historical plaintext in place; see Adding PII to an existing event type and Event redaction for the policy and remedy.
Migration expressions operate on plaintext, including values protected by [PII] or
[Encrypted]. Split, combine, and value-map operations therefore see the original
values rather than ciphertext.
Before storing a generated generation, Chronicle applies that generation’s protection metadata under the original event’s subject, falling back to its event source when no subject was supplied. This also protects default values in classified target properties. Keep the appropriate metadata on every target property that carries personal or confidential data; a migration does not replace that schema declaration.
When a new generation is registered, backfill covers the default namespace’s event log only. It migrates from the known appended generation’s base content, not a separate revision. For legacy events whose appended generation is unknown, it uses the highest stored base generation without inferring an appended generation. Each added generation records that source, whether it is the known appended generation, and a content-addressed migration-definition version. Content, hash, and provenance are written together, guarded against concurrent revision or redaction. A conflicting write is re-read and retried once; a second conflict is logged and skipped. Changing a migration alone does not rewrite existing generations.
Register new generations only after every kernel silo has been upgraded. Older workers can still run replace-all backfills during a rolling upgrade.
Before transforming the source, Chronicle releases its content under the original subject. Erasing that subject’s PII keys also makes protected PII in the generated generations unavailable; independent confidentiality keys are not erased by a PII erasure request.
Registration
Section titled “Registration”Migrations are automatically discovered and registered when you connect to Chronicle.
Simply implement IEventTypeMigrationFor<TEvent> in your client application, and
Chronicle will:
- Discover all migrators via
IClientArtifactsProvider - Build migration definitions with JmesPath transformations
- Send the definitions to the kernel during event type registration
Catching Misuse Early
Section titled “Catching Misuse Early”Two runtime checks and two code analysis rules exist specifically to stop a broken migration before it ever reaches a real event log:
MigrationGenerationsMustShareEventTypeId— thrown fromEventTypeMigration<TUpgrade, TPrevious>’s constructor the momentTUpgradeandTPreviousresolve to different event type ids. This turns the old style’s classic mistake — an omitted or mismatched id on the previous generation — into a loud failure at application startup instead of a confusing, silent rejection at the kernel.MultipleMigratorsForSameEventTypeGeneration— thrown byEventTypeMigrators.GetMigratorsForwhen two different migrator classes both claim to bridge the same generation pair for one event type. Exactly one migrator may own a generation transition.- CHR0037 flags a migration whose two generations don’t resolve to the same event type at compile time — including validating, for the new attribute, that
[EventTypeGenerationFor<T>]’sTreally is the migration’sTUpgrade. - CHR0049 flags
[EventTypeGenerationFor<T>]whenTisn’t itself marked with[EventType]— the referenced type must carry the real identity directly, not another generation marker.
Best Practices
Section titled “Best Practices”- Mark previous generations with
[EventTypeGenerationFor<T>]: it resolves the event type id from the current generation’s own[EventType], so the two can never drift apart the way a hand-typed id on both generations can - Incremental generations: Always migrate between consecutive generations (1→2, 2→3, not 1→3)
- Reversible transformations: Ensure downcast can recreate the original structure where possible
- Default values: Use
DefaultValue()for new properties that didn’t exist in older generations - Test migrations: Verify both upcast and downcast transformations work correctly
- Document changes: Keep track of what changed between generations in your event types