ExamplesUser Notifications

User Notifications

This example shows how to model in-app notification state in Causet — tracking creation, read status, and unread counts.


Design

Notifications are created by external consumers that subscribe to domain events (e.g., a service that listens to ARTIST_FOLLOWED on Kafka and creates a notification for the followed artist’s fans). Causet tracks the notification state.


Events

# events/notification.events.causet
events:
  NOTIFICATION_CREATED:
    state: user
    entity_expr: event.user_id
    payload:
      notification_id: string
      user_id:         string
      notification_type: string   # not "type" — reserved
      title:           string
      body:            string
      reference_id:    string
 
  NOTIFICATION_READ:
    state: user
    entity_expr: event.user_id
    payload:
      notification_id: string
      user_id:         string
 
  NOTIFICATION_DELETED:
    state: user
    entity_expr: event.user_id
    payload:
      notification_id: string
      user_id:         string

Actions

# actions/notification.actions.causet
actions:
  CREATE_NOTIFICATION:
    state: user
    input:
      notification_id:   { type: string, required: true }
      user_id:           { type: string, required: true }
      notification_type: { type: string, required: true }
      title:             { type: string, required: true }
      body:              { type: string, required: true }
      reference_id:      { type: string, required: false }
    side_effects:
      rules:
        - name: emit_created
          then:
            - op: emit
              event_type: NOTIFICATION_CREATED
              payload:
                notification_id:   intent.notification_id
                user_id:           intent.user_id
                notification_type: intent.notification_type
                title:             intent.title
                body:              intent.body
                reference_id:      intent.reference_id
 
  MARK_NOTIFICATION_READ:
    state: user
    input:
      notification_id: { type: string, required: true }
      user_id:         { type: string, required: true }
    side_effects:
      rules:
        - name: emit_read
          then:
            - op: emit
              event_type: NOTIFICATION_READ
              payload:
                notification_id: intent.notification_id
                user_id:         intent.user_id
 
  DELETE_NOTIFICATION:
    state: user
    input:
      notification_id: { type: string, required: true }
      user_id:         { type: string, required: true }
    side_effects:
      rules:
        - name: emit_deleted
          then:
            - op: emit
              event_type: NOTIFICATION_DELETED
              payload:
                notification_id: intent.notification_id
                user_id:         intent.user_id

Projection

# projections/notification.projections.causet
projections:
  user_notifications:
    source_events: [NOTIFICATION_CREATED, NOTIFICATION_READ, NOTIFICATION_DELETED]
    target:
      table: user_notifications
      primary_key: [notification_id]
    fields:
      notification_id:   TEXT
      user_id:           TEXT
      notification_type: TEXT
      title:             TEXT
      body:              TEXT
      reference_id:      TEXT
      read:              BOOLEAN
      deleted:           BOOLEAN
      created_at:        BIGINT
      updated_at:        BIGINT
    derive:
      notification_id:   event.notification_id
      user_id:           event.user_id
      updated_at:        event.ts
    mutations:
      NOTIFICATION_CREATED: { op: upsert }
      NOTIFICATION_READ:    { op: upsert }
      NOTIFICATION_DELETED: { op: upsert }
    indexes:
      - columns: [user_id]
      - columns: [user_id, read, deleted]

Queries

# queries/notification.queries.causet
queries:
  notifications_for_user:
    from: user_notifications
    input:
      user_id: { type: string, required: true }
    where:
      user_id: { eq: input.user_id }
      deleted: { eq: false }
    order_by:
      created_at: desc
    limit: 50
 
  unread_notification_count:
    from: user_notifications
    input:
      user_id: { type: string, required: true }
    where:
      user_id: { eq: input.user_id }
      read:    { eq: false }
      deleted: { eq: false }
    group_by:
      - user_id
    aggregate:
      unread_count: { count: notification_id }
    limit: 1

Note: The unread_notification_count query uses the group_by + aggregate pattern. A fields: - count: notification_id approach is silently dropped by the compiler. Always use the aggregate: block with a matching group_by.


External integration pattern

External services create notifications by submitting intents:

// External "notification service" — NOT inside Causet rules
// Subscribes to causet.ledger-events.v1 Kafka topic
 
async function handleArtistFollowed(event: LedgerEvent) {
  if (event.event_type !== 'ARTIST_FOLLOWED') return;
  
  const artistFollowers = await getArtistFollowers(event.payload.artist_id);
  
  for (const follower of artistFollowers) {
    await submitCausetIntent({
      action: 'CREATE_NOTIFICATION',
      entityId: follower.user_id,
      forkId: 'production',
      payload: {
        notification_id: `notif_${event.entity_id}_${follower.user_id}`,
        user_id: follower.user_id,
        notification_type: 'artist_followed',
        title: 'New follower',
        body: `${event.payload.user_id} followed ${event.payload.artist_id}`,
        reference_id: event.payload.artist_id,
      }
    });
  }
}

This pattern:

  • Keeps Causet rules deterministic (no external API calls)
  • Allows the notification service to evolve independently
  • Makes notification creation an explicit, auditable intent