Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mighty-camels-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ventyd": minor
---

feat: `migrate` api for event versioning
8 changes: 7 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["src/**/*.ts", "test/**/*.ts", "docs/**/*.tsx", "docs/**/*.ts", "docs/**/*.mdx"]
"includes": [
"src/**/*.ts",
"test/**/*.ts",
"docs/**/*.tsx",
"docs/**/*.ts",
"docs/**/*.mdx"
]
},
"formatter": {
"enabled": true,
Expand Down
150 changes: 94 additions & 56 deletions docs/content/docs/event-versioning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,42 @@ Once an event is in your event store, you can't change its structure. You need s
## Versioning Strategies

<Accordions>
<Accordion title="Strategy 1: Event Name Versioning">
<Accordion title="Strategy 1: Upcasting with migrate option">
Use the built-in `migrate` option in `createRepository` to transform old events before schema validation.

```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
</Accordion>

<Accordion title="Strategy 2: Event Name Versioning">
Create new event names for schema changes.

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

<Accordion title="Strategy 2: Upcasting">
Transform old events to new format when loading.

```typescript
interface EventUpcast {
from: string;
to: string;
transform: (body: any) => any;
}

const upcasts: EventUpcast[] = [
{
from: "user:created",
to: "user:created",
transform: (body) => ({
...body,
name: body.name || "Unknown" // Add missing field
})
}
];

// Apply upcasts when loading events
function applyUpcasts(events: Event[]): Event[] {
return events.map(event => {
const upcast = upcasts.find(u => u.from === event.eventName);
if (upcast) {
return {
...event,
body: upcast.transform(event.body)
};
}
return event;
});
}
```

**Pros:**
- Single event name
- Simpler reducer
- Centralized transformation logic

**Cons:**
- Performance overhead (transformation on every load)
- Can be complex with many migrations
</Accordion>

<Accordion title="Strategy 3: Hybrid Approach">
Combine versioning with optional upcasting.

Expand Down Expand Up @@ -199,15 +188,42 @@ event: {
// Old: "userName"
// New: "name"

// Use upcast to transform
const upcast = {
from: "user:created",
transform: (body) => ({
...body,
name: body.userName, // Rename
userName: undefined // Remove old
})
};
// 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;
},
});
```

### Renaming Event Names

Use `migrate` to upcast a legacy event name to the current one, so the old name can be removed from the schema.

```typescript
// 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;
},
});
```

## Best Practices
Expand Down Expand Up @@ -251,5 +267,27 @@ describe('Event versioning', () => {
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');
});
});
```
8 changes: 4 additions & 4 deletions docs/source.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
import { defineConfig, defineDocs } from "fumadocs-mdx/config";

export const docs = defineDocs({
dir: 'content/docs',
dir: "content/docs",
});

export default defineConfig({
mdxOptions: {
rehypeCodeOptions: {
themes: {
light: 'github-light',
dark: 'github-dark',
light: "github-light",
dark: "github-dark",
},
},
},
Expand Down
12 changes: 6 additions & 6 deletions docs/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { reactRouter } from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import mdx from 'fumadocs-mdx/vite';
import * as MdxConfig from './source.config';
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import mdx from "fumadocs-mdx/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import * as MdxConfig from "./source.config";

export default defineConfig({
plugins: [
Expand Down
37 changes: 33 additions & 4 deletions src/createRepository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import sortBy from "just-sort-by";
import type {
Adapter,
BaseEventType,
ConstructorReturnType,
DefaultSchema,
Entity,
Expand Down Expand Up @@ -131,6 +132,29 @@ export function createRepository<
error: unknown,
plugin: Plugin<InferSchemaFromEntityConstructor<$$EntityConstructor>>,
) => void;
/**
* Optional function to upcast legacy events before schema validation.
*
* @remarks
* Use this to migrate old event versions stored in the database into the
* current event format, without needing to keep legacy schemas registered.
* The function runs on each raw event before `parseEvent` validation, so
* only current event names need to be registered in the schema.
*
* @example
* ```typescript
* const userRepository = createRepository(User, {
* adapter,
* migrate(rawEvent) {
* if (rawEvent.eventName === "user:profile_updated_v1") {
* return { ...rawEvent, eventName: "user:profile_updated" };
* }
* return rawEvent;
* },
* });
* ```
*/
migrate?: (rawEvent: BaseEventType) => BaseEventType;
},
): Repository<ConstructorReturnType<$$EntityConstructor>> {
type $$Schema = InferSchemaFromEntityConstructor<$$EntityConstructor>;
Expand All @@ -148,11 +172,16 @@ export function createRepository<
entityId,
});

// 2. validate and sort events from adapter using the schema
for (const rawEvent of rawEvents) {
_schema.parseEvent(rawEvent);
// 2. migrate + validate and sort events from adapter using the schema
const migrate = args.migrate ?? ((e) => e);
const migratedEvents = rawEvents
.map((e) => e as BaseEventType)
.map(migrate) as typeof rawEvents;

for (const event of migratedEvents) {
_schema.parseEvent(event);
}
const events = sortBy(rawEvents, "eventCreatedAt");
const events = sortBy(migratedEvents, "eventCreatedAt");

if (events.length === 0) {
return null;
Expand Down
50 changes: 50 additions & 0 deletions test/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,56 @@ getAllAdapterFactories().forEach((factory) => {
expect(migratedUser?.bio).toBe("Migrated from v1");
});

test("should upcast legacy events via migrate option", async () => {
// Simulate DB having events with an old name that no longer exists in the schema
await adapter.commitEvents({
entityName: "user",
entityId: "migrate-test-user",
events: [
{
eventId: "evt-1",
eventName: "user:created",
eventCreatedAt: "2023-01-01T00:00:00.000Z",
entityId: "migrate-test-user",
entityName: "user",
body: { nickname: "MigrateUser", email: "migrate@test.com" },
},
{
eventId: "evt-2",
eventName: "user:profile_updated_v1", // old event name, not in schema
eventCreatedAt: "2023-06-01T00:00:00.000Z",
entityId: "migrate-test-user",
entityName: "user",
body: { nickname: "Updated" },
},
] as any,
state: {} as any,
});

// Without migrate: schema validation throws on the unknown event name
const repoWithoutMigrate = createRepository(User, { adapter });
await expect(
repoWithoutMigrate.findOne({ entityId: "migrate-test-user" }),
).rejects.toThrow();

// With migrate: upcast v1 → current before validation, findOne succeeds
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: "migrate-test-user",
});
expect(user?.nickname).toBe("Updated");
expect(user?.email).toBe("migrate@test.com");
});

test("should preserve chronological event order", async () => {
const userRepo = createRepository(User, {
adapter,
Expand Down
4 changes: 3 additions & 1 deletion test/typebox0.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,9 @@ getAllAdapterFactories().forEach((factory) => {

// Edit draft
article.updateTitle("My Awesome First Article");
article.updateContent("This is my awesome first article with more details");
article.updateContent(
"This is my awesome first article with more details",
);
article.updateTags(["first", "intro", "awesome"]);

// Publish
Expand Down