Skip to content

Commit 002457f

Browse files
feat: migrate api for event versioning (#68)
1 parent 6f8cecb commit 002457f

8 files changed

Lines changed: 202 additions & 72 deletions

File tree

.changeset/mighty-camels-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ventyd": minor
3+
---
4+
5+
feat: `migrate` api for event versioning

biome.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
},
88
"files": {
99
"ignoreUnknown": false,
10-
"includes": ["src/**/*.ts", "test/**/*.ts", "docs/**/*.tsx", "docs/**/*.ts", "docs/**/*.mdx"]
10+
"includes": [
11+
"src/**/*.ts",
12+
"test/**/*.ts",
13+
"docs/**/*.tsx",
14+
"docs/**/*.ts",
15+
"docs/**/*.mdx"
16+
]
1117
},
1218
"formatter": {
1319
"enabled": true,

docs/content/docs/event-versioning.mdx

Lines changed: 94 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,42 @@ Once an event is in your event store, you can't change its structure. You need s
2424
## Versioning Strategies
2525

2626
<Accordions>
27-
<Accordion title="Strategy 1: Event Name Versioning">
27+
<Accordion title="Strategy 1: Upcasting with migrate option">
28+
Use the built-in `migrate` option in `createRepository` to transform old events before schema validation.
29+
30+
```typescript
31+
const userRepository = createRepository(User, {
32+
adapter,
33+
migrate(rawEvent) {
34+
// Rename legacy event name to the current one
35+
if (rawEvent.eventName === "user:created") {
36+
return {
37+
...rawEvent,
38+
eventName: "user:created_v2",
39+
body: {
40+
...rawEvent.body,
41+
name: rawEvent.body.name || "Unknown", // Add missing field
42+
},
43+
};
44+
}
45+
return rawEvent;
46+
},
47+
});
48+
```
49+
50+
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.
51+
52+
**Pros:**
53+
- Single source of truth for migration logic
54+
- No need to keep legacy schemas registered
55+
- Centralized transformation logic
56+
57+
**Cons:**
58+
- Performance overhead (transformation on every load)
59+
- Can be complex with many migrations
60+
</Accordion>
61+
62+
<Accordion title="Strategy 2: Event Name Versioning">
2863
Create new event names for schema changes.
2964

3065
```typescript
@@ -75,52 +110,6 @@ Once an event is in your event store, you can't change its structure. You need s
75110
- Reducer becomes complex with many versions
76111
</Accordion>
77112

78-
<Accordion title="Strategy 2: Upcasting">
79-
Transform old events to new format when loading.
80-
81-
```typescript
82-
interface EventUpcast {
83-
from: string;
84-
to: string;
85-
transform: (body: any) => any;
86-
}
87-
88-
const upcasts: EventUpcast[] = [
89-
{
90-
from: "user:created",
91-
to: "user:created",
92-
transform: (body) => ({
93-
...body,
94-
name: body.name || "Unknown" // Add missing field
95-
})
96-
}
97-
];
98-
99-
// Apply upcasts when loading events
100-
function applyUpcasts(events: Event[]): Event[] {
101-
return events.map(event => {
102-
const upcast = upcasts.find(u => u.from === event.eventName);
103-
if (upcast) {
104-
return {
105-
...event,
106-
body: upcast.transform(event.body)
107-
};
108-
}
109-
return event;
110-
});
111-
}
112-
```
113-
114-
**Pros:**
115-
- Single event name
116-
- Simpler reducer
117-
- Centralized transformation logic
118-
119-
**Cons:**
120-
- Performance overhead (transformation on every load)
121-
- Can be complex with many migrations
122-
</Accordion>
123-
124113
<Accordion title="Strategy 3: Hybrid Approach">
125114
Combine versioning with optional upcasting.
126115

@@ -199,15 +188,42 @@ event: {
199188
// Old: "userName"
200189
// New: "name"
201190

202-
// Use upcast to transform
203-
const upcast = {
204-
from: "user:created",
205-
transform: (body) => ({
206-
...body,
207-
name: body.userName, // Rename
208-
userName: undefined // Remove old
209-
})
210-
};
191+
// Use the migrate option to transform on load
192+
const userRepository = createRepository(User, {
193+
adapter,
194+
migrate(rawEvent) {
195+
if (rawEvent.eventName === "user:created") {
196+
return {
197+
...rawEvent,
198+
body: {
199+
...rawEvent.body,
200+
name: rawEvent.body.userName, // Rename
201+
userName: undefined, // Remove old
202+
},
203+
};
204+
}
205+
return rawEvent;
206+
},
207+
});
208+
```
209+
210+
### Renaming Event Names
211+
212+
Use `migrate` to upcast a legacy event name to the current one, so the old name can be removed from the schema.
213+
214+
```typescript
215+
// Old event stored in DB: "user:profile_updated_v1"
216+
// Current schema only has: "user:profile_updated"
217+
218+
const userRepository = createRepository(User, {
219+
adapter,
220+
migrate(rawEvent) {
221+
if (rawEvent.eventName === "user:profile_updated_v1") {
222+
return { ...rawEvent, eventName: "user:profile_updated" };
223+
}
224+
return rawEvent;
225+
},
226+
});
211227
```
212228

213229
## Best Practices
@@ -251,5 +267,27 @@ describe('Event versioning', () => {
251267
expect(state.email).toBe('test@example.com');
252268
expect(state.name).toBe('Test');
253269
});
270+
271+
it('upcasts legacy events via migrate option', async () => {
272+
// Without migrate: schema validation throws on the unknown event name
273+
const repoWithoutMigrate = createRepository(User, { adapter });
274+
await expect(
275+
repoWithoutMigrate.findOne({ entityId: 'user-with-legacy-events' }),
276+
).rejects.toThrow();
277+
278+
// With migrate: upcast old event name before validation
279+
const repoWithMigrate = createRepository(User, {
280+
adapter,
281+
migrate(rawEvent) {
282+
if (rawEvent.eventName === 'user:profile_updated_v1') {
283+
return { ...rawEvent, eventName: 'user:profile_updated' };
284+
}
285+
return rawEvent;
286+
},
287+
});
288+
289+
const user = await repoWithMigrate.findOne({ entityId: 'user-with-legacy-events' });
290+
expect(user?.nickname).toBe('Updated');
291+
});
254292
});
255293
```

docs/source.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
1+
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
22

33
export const docs = defineDocs({
4-
dir: 'content/docs',
4+
dir: "content/docs",
55
});
66

77
export default defineConfig({
88
mdxOptions: {
99
rehypeCodeOptions: {
1010
themes: {
11-
light: 'github-light',
12-
dark: 'github-dark',
11+
light: "github-light",
12+
dark: "github-dark",
1313
},
1414
},
1515
},

docs/vite.config.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { reactRouter } from '@react-router/dev/vite';
2-
import tailwindcss from '@tailwindcss/vite';
3-
import { defineConfig } from 'vite';
4-
import tsconfigPaths from 'vite-tsconfig-paths';
5-
import mdx from 'fumadocs-mdx/vite';
6-
import * as MdxConfig from './source.config';
1+
import { reactRouter } from "@react-router/dev/vite";
2+
import tailwindcss from "@tailwindcss/vite";
3+
import mdx from "fumadocs-mdx/vite";
4+
import { defineConfig } from "vite";
5+
import tsconfigPaths from "vite-tsconfig-paths";
6+
import * as MdxConfig from "./source.config";
77

88
export default defineConfig({
99
plugins: [

src/createRepository.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import sortBy from "just-sort-by";
22
import type {
33
Adapter,
4+
BaseEventType,
45
ConstructorReturnType,
56
DefaultSchema,
67
Entity,
@@ -131,6 +132,29 @@ export function createRepository<
131132
error: unknown,
132133
plugin: Plugin<InferSchemaFromEntityConstructor<$$EntityConstructor>>,
133134
) => void;
135+
/**
136+
* Optional function to upcast legacy events before schema validation.
137+
*
138+
* @remarks
139+
* Use this to migrate old event versions stored in the database into the
140+
* current event format, without needing to keep legacy schemas registered.
141+
* The function runs on each raw event before `parseEvent` validation, so
142+
* only current event names need to be registered in the schema.
143+
*
144+
* @example
145+
* ```typescript
146+
* const userRepository = createRepository(User, {
147+
* adapter,
148+
* migrate(rawEvent) {
149+
* if (rawEvent.eventName === "user:profile_updated_v1") {
150+
* return { ...rawEvent, eventName: "user:profile_updated" };
151+
* }
152+
* return rawEvent;
153+
* },
154+
* });
155+
* ```
156+
*/
157+
migrate?: (rawEvent: BaseEventType) => BaseEventType;
134158
},
135159
): Repository<ConstructorReturnType<$$EntityConstructor>> {
136160
type $$Schema = InferSchemaFromEntityConstructor<$$EntityConstructor>;
@@ -148,11 +172,16 @@ export function createRepository<
148172
entityId,
149173
});
150174

151-
// 2. validate and sort events from adapter using the schema
152-
for (const rawEvent of rawEvents) {
153-
_schema.parseEvent(rawEvent);
175+
// 2. migrate + validate and sort events from adapter using the schema
176+
const migrate = args.migrate ?? ((e) => e);
177+
const migratedEvents = rawEvents
178+
.map((e) => e as BaseEventType)
179+
.map(migrate) as typeof rawEvents;
180+
181+
for (const event of migratedEvents) {
182+
_schema.parseEvent(event);
154183
}
155-
const events = sortBy(rawEvents, "eventCreatedAt");
184+
const events = sortBy(migratedEvents, "eventCreatedAt");
156185

157186
if (events.length === 0) {
158187
return null;

test/integration.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,56 @@ getAllAdapterFactories().forEach((factory) => {
358358
expect(migratedUser?.bio).toBe("Migrated from v1");
359359
});
360360

361+
test("should upcast legacy events via migrate option", async () => {
362+
// Simulate DB having events with an old name that no longer exists in the schema
363+
await adapter.commitEvents({
364+
entityName: "user",
365+
entityId: "migrate-test-user",
366+
events: [
367+
{
368+
eventId: "evt-1",
369+
eventName: "user:created",
370+
eventCreatedAt: "2023-01-01T00:00:00.000Z",
371+
entityId: "migrate-test-user",
372+
entityName: "user",
373+
body: { nickname: "MigrateUser", email: "migrate@test.com" },
374+
},
375+
{
376+
eventId: "evt-2",
377+
eventName: "user:profile_updated_v1", // old event name, not in schema
378+
eventCreatedAt: "2023-06-01T00:00:00.000Z",
379+
entityId: "migrate-test-user",
380+
entityName: "user",
381+
body: { nickname: "Updated" },
382+
},
383+
] as any,
384+
state: {} as any,
385+
});
386+
387+
// Without migrate: schema validation throws on the unknown event name
388+
const repoWithoutMigrate = createRepository(User, { adapter });
389+
await expect(
390+
repoWithoutMigrate.findOne({ entityId: "migrate-test-user" }),
391+
).rejects.toThrow();
392+
393+
// With migrate: upcast v1 → current before validation, findOne succeeds
394+
const repoWithMigrate = createRepository(User, {
395+
adapter,
396+
migrate(rawEvent) {
397+
if (rawEvent.eventName === "user:profile_updated_v1") {
398+
return { ...rawEvent, eventName: "user:profile_updated" };
399+
}
400+
return rawEvent;
401+
},
402+
});
403+
404+
const user = await repoWithMigrate.findOne({
405+
entityId: "migrate-test-user",
406+
});
407+
expect(user?.nickname).toBe("Updated");
408+
expect(user?.email).toBe("migrate@test.com");
409+
});
410+
361411
test("should preserve chronological event order", async () => {
362412
const userRepo = createRepository(User, {
363413
adapter,

test/typebox0.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,9 @@ getAllAdapterFactories().forEach((factory) => {
651651

652652
// Edit draft
653653
article.updateTitle("My Awesome First Article");
654-
article.updateContent("This is my awesome first article with more details");
654+
article.updateContent(
655+
"This is my awesome first article with more details",
656+
);
655657
article.updateTags(["first", "intro", "awesome"]);
656658

657659
// Publish

0 commit comments

Comments
 (0)