| title | Event Versioning Strategy |
|---|---|
| description | Handle event schema evolution and migrations |
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
Event schemas evolve over time. This guide covers strategies for managing event versioning, schema migrations, and backward compatibility.
Once an event is in your event store, you can't change its structure. You need strategies to handle evolution without breaking existing events.
// Old events still exist:
{ eventName: "user:created", body: { email: "..." } }
// But new code expects:
{ eventName: "user:created", body: { email: "...", name: "..." } }```typescript
const userRepository = createRepository(User, {
adapter,
migrate(rawEvent) {
// Rename legacy event name to the current one
if (rawEvent.eventName === "user:created") {
return {
...rawEvent,
eventName: "user:created_v2",
body: {
...rawEvent.body,
name: rawEvent.body.name || "Unknown", // Add missing field
},
};
}
return rawEvent;
},
});
```
The `migrate` function runs on each raw event **before** schema validation, so only current event names need to be registered in the schema. Legacy event names can be removed from the schema entirely.
**Pros:**
- Single source of truth for migration logic
- No need to keep legacy schemas registered
- Centralized transformation logic
**Cons:**
- Performance overhead (transformation on every load)
- Can be complex with many migrations
```typescript
const userSchema = defineSchema("user", {
schema: valibot({
event: {
// V1: Original
created: v.object({
email: v.string()
}),
// V2: Added name field
created_v2: v.object({
email: v.string(),
name: v.string()
})
},
state: v.object({
email: v.string(),
name: v.optional(v.string()) // Optional for backwards compatibility
})
}),
initialEventName: "user:created_v2" // Use latest version
});
```
**Reducer handles both versions:**
```typescript
const reducer = (state, event) => {
switch (event.eventName) {
case "user:created":
return { email: event.body.email, name: undefined };
case "user:created_v2":
return { email: event.body.email, name: event.body.name };
default:
return state;
}
};
```
**Pros:**
- Simple and explicit
- Easy to understand
- No data transformation needed
**Cons:**
- Event name proliferation
- Reducer becomes complex with many versions
```typescript
// Use event versions for major changes
const schema = defineSchema("user", {
schema: valibot({
event: {
created: v.object({ email: v.string() }),
created_v2: v.object({ email: v.string(), name: v.string() })
},
state: v.object({
email: v.string(),
name: v.string()
})
}),
initialEventName: "user:created_v2"
});
// Use upcasting for minor changes within versions
const upcasts = [
{
from: "user:created",
transform: (body) => ({ ...body, name: "Unknown" })
}
];
```
**When to use:**
- Major breaking changes: New event version
- Minor additions: Upcast
- Critical migrations: Batch transform old events
// Old: email only
// New: email + optional name
// ✅ Make field optional in state
state: v.object({
email: v.string(),
name: v.optional(v.string())
})
// ✅ Reducer provides default
case "user:created":
return { email: body.email, name: body.name || undefined };// Old: email + deprecated field
// New: email only
// ✅ Keep field optional for old events
state: v.object({
email: v.string(),
deprecatedField: v.optional(v.string())
})
// ✅ Don't include in new events
event: {
created: v.object({ email: v.string() }) // No deprecated field
}// Old: "userName"
// New: "name"
// Use the migrate option to transform on load
const userRepository = createRepository(User, {
adapter,
migrate(rawEvent) {
if (rawEvent.eventName === "user:created") {
return {
...rawEvent,
body: {
...rawEvent.body,
name: rawEvent.body.userName, // Rename
userName: undefined, // Remove old
},
};
}
return rawEvent;
},
});Use migrate to upcast a legacy event name to the current one, so the old name can be removed from the schema.
// Old event stored in DB: "user:profile_updated_v1"
// Current schema only has: "user:profile_updated"
const userRepository = createRepository(User, {
adapter,
migrate(rawEvent) {
if (rawEvent.eventName === "user:profile_updated_v1") {
return { ...rawEvent, eventName: "user:profile_updated" };
}
return rawEvent;
},
});- Version only when necessary - Don't version for every small change
- Document migrations - Keep a changelog of schema changes
- Test with old data - Ensure reducers handle all event versions
- Monitor performance - Upcasting can impact load times
- Plan for rollback - New versions should be backwards compatible
Consider migrating when:
- Event versions proliferate (>3 versions)
- Performance degrades from upcasts
- Old events are no longer accessed
- Major refactoring is needed
describe('Event versioning', () => {
it('handles old event format', () => {
const oldEvent = {
eventName: 'user:created',
body: { email: 'test@example.com' }
};
const state = reducer(undefined, oldEvent);
expect(state.email).toBe('test@example.com');
expect(state.name).toBe(undefined); // Missing in old format
});
it('handles new event format', () => {
const newEvent = {
eventName: 'user:created_v2',
body: { email: 'test@example.com', name: 'Test' }
};
const state = reducer(undefined, newEvent);
expect(state.email).toBe('test@example.com');
expect(state.name).toBe('Test');
});
it('upcasts legacy events via migrate option', async () => {
// Without migrate: schema validation throws on the unknown event name
const repoWithoutMigrate = createRepository(User, { adapter });
await expect(
repoWithoutMigrate.findOne({ entityId: 'user-with-legacy-events' }),
).rejects.toThrow();
// With migrate: upcast old event name before validation
const repoWithMigrate = createRepository(User, {
adapter,
migrate(rawEvent) {
if (rawEvent.eventName === 'user:profile_updated_v1') {
return { ...rawEvent, eventName: 'user:profile_updated' };
}
return rawEvent;
},
});
const user = await repoWithMigrate.findOne({ entityId: 'user-with-legacy-events' });
expect(user?.nickname).toBe('Updated');
});
});