projections — Materialized Read Models

Projections subscribe to events and maintain PostgreSQL tables. They are the read side of Causet.

projections:
  user_concert_stats:
    description: "Per-user concert attendance stats"
    source_events:
      - SHOW_CHECK_IN_CREATED
    target:
      table: user_concert_stats
      primary_key: [user_id]
    fields:                              # always declare explicitly
      user_id:        TEXT
      shows_attended: BIGINT
      venues_visited: BIGINT
      last_checkin_at: BIGINT
    derive:
      user_id:        event.user_id
      last_checkin_at: event.ts
    mutations:
      SHOW_CHECK_IN_CREATED:
        op: upsert
        set:
          shows_attended: { aggregate: count }
          venues_visited: { aggregate: count_distinct, field: event.venue_id }
    indexes:
      - columns: [user_id]
      - columns: [last_checkin_at]
        direction: desc

Projection fields

FieldRequiredDescription
source_eventsyesList of event types that update this projection
target.tableyesPostgreSQL table name
target.primary_keyyesList of column names forming the PK
fieldsstrongly recommendedExplicit SQL column types
deriveyesExpression map for column values
mutationsyes*Per-event operation (upsert, delete, replace)
aggregatesyes*Counter-style increments (add, sub, set)
indexesnoAdditional indexes
bucketnoTime-window bucketing
resolvernoNamed entity lookups in derive expressions

*At least one of mutations or aggregates must be present.

SQL field types

TEXT · VARCHAR · STRING · INT · INTEGER · SMALLINT · BIGINT · DOUBLE · FLOAT · NUMERIC · DECIMAL · BOOLEAN · BOOL · TIMESTAMP · TIMESTAMPTZ · DATE · UUID · JSONB · JSON · GEOGRAPHY_POINT · GEOGRAPHY

🚫

Column type inference gotcha

If you omit fields:, the compiler infers every column type from the first literal it sees in derive:. A value like false infers TEXT, which breaks WHERE read = false queries (Postgres casts 'false' → integer → error).

Always declare fields: with explicit SQL types for any column that is boolean, bigint, or timestamp.

Mutation ops

OpBehavior
upsertINSERT … ON CONFLICT UPDATE (default)
deleteDELETE row matching primary key
replaceDELETE then INSERT
mutations:
  ARTIST_FOLLOWED:   { op: upsert }
  ARTIST_UNFOLLOWED: { op: delete }

Aggregate ops

Counter-style mutations using aggregates:

aggregates:
  SHOW_CHECK_IN_CREATED:
    shows_attended: { op: add, by: 1, floor: 0 }
    unique_venue_count:
      op: add
      by: 1
      floor: 0
Aggregate opDescription
addIncrement the column
subDecrement the column
setSet to a specific value

floor: N sets the minimum value after the operation.

Alternatively, use inline aggregate syntax in mutations.set:

mutations:
  SHOW_CHECK_IN_CREATED:
    op: upsert
    set:
      shows_attended: { aggregate: count }
      unique_venues:  { aggregate: count_distinct, field: event.venue_id }

Derive expressions

ExpressionContextDescription
event.<field>deriveEvent payload field
event.tsderiveEvent timestamp (epoch ms)
event.entity_idderiveRouting entity ID
event.payload.<field>deriveBackwards-compat alias for event.<field>
shard(event.artist_id, 16)deriveShard ID (0–15) for hot-aggregate sharding
lookup(entity_type, id_expr).<field>deriveCross-entity lookup

Bucketed projections

Use bucket: for time-window metrics:

projections:
  hourly_checkin_counts:
    source_events: [SHOW_CHECK_IN_CREATED]
    target:
      table: hourly_checkin_counts
      primary_key: [venue_id, bucket_start]
    fields:
      venue_id:     TEXT
      bucket_start: BIGINT
      checkin_count: BIGINT
    derive:
      venue_id:  event.venue_id
    aggregates:
      SHOW_CHECK_IN_CREATED:
        checkin_count: { op: add, by: 1, floor: 0 }
    bucket:
      field:    event.ts
      interval: "1h"          # 1h | 24h | 1d | 5m

Pair with a query that uses gte_window to filter by time window.

Indexes

indexes:
  - columns: [user_id]
  - columns: [last_checkin_at]
    direction: desc
  - columns: [location]
    type: GIST              # GIST for GEOGRAPHY_POINT columns
  - columns: [tags]
    type: GIN               # GIN for TEXT search

Entity resolver

Use resolver: to look up another entity’s state in derive: expressions:

projections:
  enriched_checkins:
    source_events: [SHOW_CHECK_IN_CREATED]
    resolver:
      show:
        entity: show
        key: event.show_id
    derive:
      title:   "show.title"
      user_id: event.user_id