Skip to content

Commit 5cd4e7d

Browse files
authored
Merge pull request #2840 from Cratis/copilot/support-migration-of-events
Support migration of events between generations (up & down casting)
2 parents e7cb309 + 884957b commit 5cd4e7d

File tree

76 files changed

+2929
-964
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+2929
-964
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"Insurtech",
9393
"Intrinsics",
9494
"IPII",
95+
"jmes",
9596
"maxcpucount",
9697
"Meziantou",
9798
"middlewares",

Directory.Packages.props

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<!-- System -->
88
<PackageVersion Include="protobuf-net.Grpc.AspNetCore.Reflection" Version="1.2.2" />
99
<PackageVersion Include="protobuf-net.Grpc.Reflection" Version="1.2.2" />
10-
<PackageVersion Include="protobuf-net.Reflection" Version="3.2.52" />
1110
<PackageVersion Include="System.Reactive" Version="6.1.0" />
1211
<PackageVersion Include="System.Text.Encoding.Extensions" Version="4.3.0" />
1312
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
@@ -67,8 +66,6 @@
6766
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.15.0" />
6867
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.6.0" />
6968
<!-- Roslyn-->
70-
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
71-
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
7269
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
7370
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
7471
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
@@ -87,6 +84,7 @@
8784
<PackageVersion Include="humanizer" Version="3.0.10" />
8885
<PackageVersion Include="Mono.Cecil" Version="0.11.6" />
8986
<PackageVersion Include="NJsonSchema" Version="11.5.2" />
87+
<PackageVersion Include="JsonCons.JmesPath" Version="1.0.0" />
9088
<PackageVersion Include="protobuf-net.BuildTools" Version="3.2.52">
9189
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
9290
<PrivateAssets>all</PrivateAssets>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Event Type Migrations
2+
3+
Event type migrations enable you to evolve your event schemas over time while maintaining
4+
compatibility with existing events. When an event type changes, you can define **upcasters**
5+
and **downcasters** that automatically transform events between different generations.
6+
7+
## Why Migrations?
8+
9+
In evolving systems, event schemas naturally change:
10+
- Properties are added or removed
11+
- Properties are renamed
12+
- Complex properties are split or combined
13+
14+
Chronicle's migration system allows you to:
15+
1. Define declarative transformation rules
16+
2. Automatically store all generations of an event when appending
17+
3. Read events in any generation format
18+
19+
## Defining Migrations
20+
21+
To define a migration, implement the `IEventTypeMigrationFor<TEvent>` interface:
22+
23+
```csharp
24+
public record AuthorRegisteredV1(string Name);
25+
26+
[EventType("author-registered", 2)]
27+
public record AuthorRegistered(string FirstName, string LastName);
28+
29+
public class AuthorRegisteredMigrator : IEventTypeMigrationFor<AuthorRegistered>
30+
{
31+
public EventTypeGeneration From => 1;
32+
public EventTypeGeneration To => 2;
33+
34+
public void Upcast(IEventMigrationBuilder builder)
35+
{
36+
builder.Properties(pb =>
37+
{
38+
pb.Split("Name", " ", 0); // FirstName from first part of Name
39+
pb.Split("Name", " ", 1); // LastName from second part of Name
40+
});
41+
}
42+
43+
public void Downcast(IEventMigrationBuilder builder)
44+
{
45+
builder.Properties(pb =>
46+
{
47+
pb.Combine("FirstName", "LastName"); // Combine back to Name
48+
});
49+
}
50+
}
51+
```
52+
53+
## Migration Operations
54+
55+
The migration builder supports the following operations:
56+
57+
### Split
58+
59+
Splits a source property into parts using a separator:
60+
61+
```csharp
62+
pb.Split("FullName", " ", 0); // Gets first part
63+
pb.Split("FullName", " ", 1); // Gets second part
64+
```
65+
66+
### Combine
67+
68+
Combines multiple properties into a single value:
69+
70+
```csharp
71+
pb.Combine("FirstName", "LastName"); // Joins with space separator
72+
```
73+
74+
### Rename
75+
76+
Renames a property from a previous name:
77+
78+
```csharp
79+
pb.RenamedFrom("OldPropertyName");
80+
```
81+
82+
### Default Value
83+
84+
Sets a default value for a new property:
85+
86+
```csharp
87+
pb.DefaultValue(42);
88+
pb.DefaultValue("default string");
89+
```
90+
91+
## How Migrations Work
92+
93+
When an event is appended to the event store:
94+
95+
1. Chronicle identifies the event's current generation
96+
2. The migration system retrieves all registered migrations for the event type
97+
3. **Upcasting**: If there are higher generations, the event is transformed upward (1→2→3)
98+
4. **Downcasting**: If there are lower generations, the event is transformed downward (3→2→1)
99+
5. All generations are stored in the event sequence
100+
101+
This ensures that:
102+
- Older consumers can still read events in their expected format
103+
- Newer consumers can read events with the latest schema
104+
- No data is lost during schema evolution
105+
106+
## Registration
107+
108+
Migrations are automatically discovered and registered when you connect to Chronicle.
109+
Simply implement `IEventTypeMigrationFor<TEvent>` in your client application, and
110+
Chronicle will:
111+
112+
1. Discover all migrators via `IClientArtifactsProvider`
113+
2. Build migration definitions with JmesPath transformations
114+
3. Send the definitions to the kernel during event type registration
115+
116+
## Best Practices
117+
118+
1. **Incremental generations**: Always migrate between consecutive generations (1→2, 2→3, not 1→3)
119+
2. **Reversible transformations**: Ensure downcast can recreate the original structure where possible
120+
3. **Default values**: Use `DefaultValue()` for new properties that didn't exist in older generations
121+
4. **Test migrations**: Verify both upcast and downcast transformations work correctly
122+
5. **Document changes**: Keep track of what changed between generations in your event types

Documentation/concepts/event-type.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ The concept of generations is important when working with systems that evolve ov
1414
Each generation registers its own JSON schema, which Chronicle uses to validate and store
1515
events correctly for that generation.
1616

17+
For detailed information on how to define and use migrations, see [Event Type Migrations](./event-type-migrations.md).

Documentation/concepts/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
href: event.md
33
- name: Event Type
44
href: event-type.md
5+
- name: Event Type Migrations
6+
href: event-type-migrations.md
57
- name: Event Source
68
href: event-source.md
79
- name: Event Store
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# C# client usage
2+
3+
This guide covers how to declare event type migrations in a .NET client, the operations available to you, and what happens when your migrators are registered with the Chronicle Kernel.
4+
5+
## Prerequisites
6+
7+
- A Chronicle-enabled .NET application
8+
- An event type marked with `[EventType]` that has evolved beyond generation 1
9+
10+
## Marking an event type with a generation
11+
12+
Every `[EventType]` that has evolved past its first version must declare its current generation. The generation is part of the event type identity:
13+
14+
```csharp
15+
// Generation 1 (original) — no explicit generation needed, defaults to 1
16+
[EventType]
17+
public record AuthorRegistered(string Name);
18+
```
19+
20+
When the schema changes, the new record carries the higher generation. The old record can be kept for documentation or removed — Chronicle identifies events by the type name, not the .NET class:
21+
22+
```csharp
23+
// Generation 2 — Name has been split into FirstName and LastName
24+
[EventType(2)]
25+
public record AuthorRegistered(string FirstName, string LastName);
26+
```
27+
28+
## Defining a migrator
29+
30+
Implement `IEventTypeMigrationFor<TEvent>` where `TEvent` is the **latest generation** of the event type. The interface requires:
31+
32+
- `From` — the generation this migrator reads from
33+
- `To` — the generation this migrator produces
34+
- `Upcast(IEventMigrationBuilder)` — transformation from `From` to `To`
35+
- `Downcast(IEventMigrationBuilder)` — transformation from `To` back to `From`
36+
37+
```csharp
38+
using Cratis.Chronicle.Events;
39+
using Cratis.Chronicle.Events.Migrations;
40+
41+
public class AuthorRegisteredMigrator : IEventTypeMigrationFor<AuthorRegistered>
42+
{
43+
public EventTypeGeneration From => 1;
44+
public EventTypeGeneration To => 2;
45+
46+
public void Upcast(IEventMigrationBuilder builder)
47+
{
48+
builder.Properties(pb =>
49+
{
50+
var firstName = pb.Split("Name", separator: " ", part: SplitPartIndex.First);
51+
var lastName = pb.Split("Name", separator: " ", part: SplitPartIndex.Second);
52+
});
53+
}
54+
55+
public void Downcast(IEventMigrationBuilder builder)
56+
{
57+
builder.Properties(pb =>
58+
{
59+
var name = pb.Combine("FirstName", "LastName");
60+
});
61+
}
62+
}
63+
```
64+
65+
Migrators are discovered automatically at startup — no explicit registration is needed.
66+
67+
## Migration operations
68+
69+
All operations return a `PropertyExpression` that identifies the expression in the migration definition. The property name you assign to the returned expression in `Properties()` becomes the output property name in the transformed event.
70+
71+
### Split
72+
73+
Extracts one segment of a string property by splitting it on a separator.
74+
75+
```csharp
76+
builder.Properties(pb =>
77+
{
78+
var firstName = pb.Split("FullName", separator: " ", part: 0); // first segment
79+
var lastName = pb.Split("FullName", separator: " ", part: 1); // second segment
80+
});
81+
```
82+
83+
Use `SplitPartIndex.First` and `SplitPartIndex.Second` for the most common cases:
84+
85+
```csharp
86+
var firstName = pb.Split("FullName", " ", SplitPartIndex.First);
87+
var lastName = pb.Split("FullName", " ", SplitPartIndex.Second);
88+
```
89+
90+
### Combine
91+
92+
Joins multiple source properties into a single string value, separated by a space.
93+
94+
```csharp
95+
builder.Properties(pb =>
96+
{
97+
var fullName = pb.Combine("FirstName", "LastName");
98+
});
99+
```
100+
101+
### RenamedFrom
102+
103+
Reads a property value from an old property name. Use this when a property is being renamed between generations.
104+
105+
```csharp
106+
builder.Properties(pb =>
107+
{
108+
var email = pb.RenamedFrom("EmailAddress"); // was EmailAddress, now Email
109+
});
110+
```
111+
112+
### DefaultValue
113+
114+
Provides a literal default value for a property that did not exist in the source generation.
115+
116+
```csharp
117+
builder.Properties(pb =>
118+
{
119+
var status = pb.DefaultValue("active");
120+
var retries = pb.DefaultValue(0);
121+
var enabled = pb.DefaultValue(true);
122+
});
123+
```
124+
125+
## Multi-generation migrations
126+
127+
If your event type spans more than two generations, define one migrator per generation pair. Chronicle chains the migrators automatically:
128+
129+
```csharp
130+
// Generation 1 → 2 migrator
131+
public class PersonRegisteredV1ToV2 : IEventTypeMigrationFor<PersonRegistered>
132+
{
133+
public EventTypeGeneration From => 1;
134+
public EventTypeGeneration To => 2;
135+
136+
public void Upcast(IEventMigrationBuilder builder) =>
137+
builder.Properties(pb =>
138+
{
139+
var email = pb.RenamedFrom("EmailAddress");
140+
});
141+
142+
public void Downcast(IEventMigrationBuilder builder) =>
143+
builder.Properties(pb =>
144+
{
145+
var emailAddress = pb.RenamedFrom("Email");
146+
});
147+
}
148+
149+
// Generation 2 → 3 migrator
150+
public class PersonRegisteredV2ToV3 : IEventTypeMigrationFor<PersonRegistered>
151+
{
152+
public EventTypeGeneration From => 2;
153+
public EventTypeGeneration To => 3;
154+
155+
public void Upcast(IEventMigrationBuilder builder) =>
156+
builder.Properties(pb =>
157+
{
158+
var firstName = pb.Split("Name", " ", SplitPartIndex.First);
159+
var lastName = pb.Split("Name", " ", SplitPartIndex.Second);
160+
});
161+
162+
public void Downcast(IEventMigrationBuilder builder) =>
163+
builder.Properties(pb =>
164+
{
165+
var name = pb.Combine("FirstName", "LastName");
166+
});
167+
}
168+
```
169+
170+
When a generation 1 event arrives, the Kernel chains the upcasts: 1→2, then 2→3, and stores all three generations.
171+
172+
## How registration works
173+
174+
When your application connects to Chronicle, the client:
175+
176+
1. Discovers all `IEventTypeMigrationFor<T>` implementations via `IClientArtifactsProvider`
177+
2. Invokes `Upcast` and `Downcast` on each migrator to capture the transformation declarations
178+
3. Converts the declarations into JmesPath expressions
179+
4. Sends the complete `EventTypeDefinition` — including all generations and their migration definitions — to the Kernel during event type registration
180+
181+
From that point on, the Kernel applies the migrations autonomously on every event append, without any further involvement from the client.
182+
183+
## Validation: missing migrators
184+
185+
If an event type is declared with a generation higher than 1 but has no migrators covering all generations up to the current one, Chronicle throws `MissingEventTypeMigrators` during startup. This prevents silent data loss from an incomplete migration chain.
186+
187+
```text
188+
Cratis.Chronicle.Events.Migrations.MissingEventTypeMigrators:
189+
Event type 'AuthorRegistered' is at generation 3 but no migrators are registered for it.
190+
```
191+
192+
Ensure every generation gap has a corresponding `IEventTypeMigrationFor<T>` implementation before deploying an event type with a new generation.

0 commit comments

Comments
 (0)