Skip to content

Latest commit

 

History

History
293 lines (242 loc) · 7.67 KB

File metadata and controls

293 lines (242 loc) · 7.67 KB
title Event Versioning Strategy
description Handle event schema evolution and migrations

import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';

Overview

Event schemas evolve over time. This guide covers strategies for managing event versioning, schema migrations, and backward compatibility.

The Versioning Challenge

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: "..." } }

Versioning Strategies

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
Create new event names for schema changes.
```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
Combine versioning with optional upcasting.
```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

Migration Scenarios

Adding Optional Fields

// 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 };

Removing Fields

// 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
}

Renaming Fields

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

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.

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

  • 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

When to Migrate

Consider migrating when:

  • Event versions proliferate (>3 versions)
  • Performance degrades from upcasts
  • Old events are no longer accessed
  • Major refactoring is needed

Testing

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');
  });
});