EventsEvent Versioning

Event Versioning

Events are stable contracts. Once deployed, an event type’s name and payload shape are referenced by the ledger, projections, listeners, and sagas. Changes require careful planning.


Additive Changes (Safe)

Adding optional payload fields to an existing event is backward compatible:

# Before
events:
  TICKET_PURCHASED:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:   string
      show_id:   string
      price:     number
 
# After — adding optional field
events:
  TICKET_PURCHASED:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:      string
      show_id:      string
      price:        number
      currency:     string   # new optional field
      purchased_at: datetime # new optional field

Safe because:

  • Existing projection handlers receive event.currency as null for old events.
  • Existing ledger entries are unaffected.
  • No replay required.

Note: When adding a field used in a projection’s derive expression, guard against null for historical events:

derive:
  currency: { expr: "event.currency != null ? event.currency : 'USD'" }

Breaking Changes

The following changes are breaking — they affect existing ledger entries, projections, or consumers:

ChangeWhy It Breaks
Removing a payload fieldProjections that reference event.removed_field will fail
Renaming a payload fieldProjections that reference the old name will break
Changing a field typeDownstream consumers expecting the old type will fail
Renaming the event typeProjections subscribed to the old name stop firing

Breaking Change Strategy: Add a V2 Event

The recommended strategy for breaking changes is to introduce a new event type and deprecate the old one:

events:
  # Deprecated — keep emitting for backward compatibility during transition
  TICKET_PURCHASED:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:   string
      show_id:   string
      price:     number    # this was the breaking field
 
  # New version — clean payload
  TICKET_PURCHASED_V2:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:        string
      show_id:        string
      price_cents:    int       # renamed + type change
      currency:       string
      purchased_at:   datetime

Migration procedure

  1. Deploy the new IR version with both TICKET_PURCHASED and TICKET_PURCHASED_V2.
  2. Update the action that emits TICKET_PURCHASED to emit TICKET_PURCHASED_V2 instead.
  3. Update projections to subscribe to both event types during the transition period:
    projections:
      user_purchases:
        source_events: [TICKET_PURCHASED, TICKET_PURCHASED_V2]
        mutations:
          TICKET_PURCHASED:    { op: upsert }
          TICKET_PURCHASED_V2: { op: upsert }
        derive:
          # Handle both event shapes
          price_cents: { expr: "event.price_cents != null ? event.price_cents : event.price * 100" }
  4. Once all historical events are TICKET_PURCHASED_V2 (and old projections are rebuilt), remove the old event type from the DSL.

Projection Compatibility During Migration

While running both old and new event versions, projections must handle both. Use conditional derive expressions to handle both shapes:

derive:
  # Normalize price to cents regardless of which event version
  price_cents: { expr: "event.price_cents != null ? event.price_cents : (event.price * 100)" }
  currency:    { expr: "event.currency != null ? event.currency : 'USD'" }

This pattern works because:

  • event.price_cents is null on old TICKET_PURCHASED events (field didn’t exist).
  • event.price is null on new TICKET_PURCHASED_V2 events (field was removed).

Compiler Validation

The Causet compiler validates:

  • All field references in derive expressions exist in the source event’s payload (or are reserved fields).
  • All source_events declared in projections exist as declared events.
  • emit operations reference events that exist in the DSL.

If you rename a payload field, the compiler catches all references to the old name:

Error: projection 'user_purchases' references 'event.price' in derive.price_cents,
       but TICKET_PURCHASED_V2 has no field 'price'. Did you mean 'price_cents'?

IR Versioning

Every compile produces a new irVersion. The irVersion is stored on each ledger event — you can always know which DSL version was in effect when an event was emitted:

SELECT DISTINCT ir_version, event_type, COUNT(*)
FROM ledger_events
WHERE entity_id = 'user-123'
GROUP BY ir_version, event_type;

This is essential for debugging payload shape discrepancies — an event emitted with IR v39 may have different fields than the same event type emitted with IR v42.


Deprecation and Cleanup

Once all projections and consumers have migrated to the new event version:

  1. Remove the old event type from the DSL.
  2. Compile a new IR version.
  3. Deploy and activate the new IR version.
  4. The old event type remains in the ledger (immutable history) but is no longer emitted.

Projections subscribed to the old event type will stop receiving events once the old action no longer emits them. Old events in the ledger are unaffected — they are historical records.