ExamplesConcert App

Concert App

This is a complete Causet application for a concert discovery and attendance tracking domain (Jamlet-style). It extends the Complete Concert App getting-started tutorial with check-ins, going, reviews, and richer projections.


Domain model

Users follow artists. Artists announce shows at venues. Users mark themselves as going, check in when they arrive, and submit reviews afterward. All of this creates memories — a record of a user’s concert history.


File structure

concert-app/
  app.causet
  states/
    user.state.causet
    artist.state.causet
    show.state.causet
    venue.state.causet
  events/
    follow.events.causet
    show.events.causet
    attendance.events.causet
    memory.events.causet
  actions/
    follow.actions.causet
    show.actions.causet
    attendance.actions.causet
    review.actions.causet
  relationships/
    follow.relationships.causet
  projections/
    concert_memory.projections.causet
    artist_popularity.projections.causet
    show_attendance.projections.causet
    friend_activity.projections.causet
  queries/
    shows.queries.causet
    artist.queries.causet
    user.queries.causet

App manifest

# app.causet
dsl_version: 1
app: concert_app
 
includes:
  states:
    - ./states/**/*.state.causet
  events:
    - ./events/**/*.events.causet
  actions:
    - ./actions/**/*.actions.causet
  projections:
    - ./projections/**/*.projections.causet
  queries:
    - ./queries/**/*.queries.causet
  relationships:
    - ./relationships/**/*.relationships.causet

Entities (states)

# states/user.state.causet
state:
  user:
    entity_key: user_id
    fields:
      - name: following_count
        type: int
        default: 0
      - name: checkin_count
        type: int
        default: 0
      - name: blocked_artists
        type: string_list
        default: []
# states/artist.state.causet
state:
  artist:
    entity_key: artist_id
    fields:
      - name: name
        type: string
        default: ""
      - name: follower_count
        type: int
        default: 0
      - name: show_count
        type: int
        default: 0
      - name: verified
        type: boolean
        default: false
# states/show.state.causet
state:
  show:
    entity_key: show_id
    fields:
      - name: artist_id
        type: string
        default: ""
      - name: venue_id
        type: string
        default: ""
      - name: title
        type: string
        default: ""
      - name: date
        type: string
        default: ""
      - name: status
        type: string
        default: "announced"   # announced | cancelled | completed
      - name: going_count
        type: int
        default: 0
      - name: checkin_count
        type: int
        default: 0
# states/venue.state.causet
state:
  venue:
    entity_key: venue_id
    fields:
      - name: name
        type: string
        default: ""
      - name: city
        type: string
        default: ""
      - name: capacity
        type: int
        default: 0
      - name: show_count
        type: int
        default: 0

Events

Follow events

# events/follow.events.causet
events:
  ARTIST_FOLLOWED:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:   string
      artist_id: string
 
  ARTIST_UNFOLLOWED:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:   string
      artist_id: string

Show events

# events/show.events.causet
events:
  SHOW_ANNOUNCED:
    state: show
    entity_expr: event.show_id
    payload:
      show_id:   string
      artist_id: string
      venue_id:  string
      title:     string
      date:      string
 
  SHOW_IMPORTED:
    state: show
    entity_expr: event.show_id
    payload:
      show_id:   string
      artist_id: string
      venue_id:  string
      title:     string
      date:      string
      source:    string   # "ticketmaster" | "songkick" | etc.

Attendance events

# events/attendance.events.causet
events:
  USER_MARKED_GOING:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:  string
      show_id:  string
 
  USER_CHECKED_IN:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:   string
      show_id:   string
      artist_id: string
      venue_id:  string

Memory events

# events/memory.events.causet
events:
  CONCERT_MEMORY_CREATED:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:    string
      show_id:    string
      artist_id:  string
      venue_id:   string
      checked_in: boolean
 
  REVIEW_SUBMITTED:
    state: user
    entity_expr: event.user_id
    payload:
      user_id:   string
      show_id:   string
      artist_id: string
      rating:    int      # 1–5
      notes:     string

Relationships

# relationships/follow.relationships.causet
relationships:
  artist_followers:
    from: user
    to:   artist
    cardinality: many_to_many
    unique: true
    emit_events:
      created: ARTIST_FOLLOWED
      removed: ARTIST_UNFOLLOWED
 
  show_going:
    from: user
    to:   show
    cardinality: many_to_many
    unique: true
    emit_events:
      created: USER_MARKED_GOING

The relationship enforces uniqueness — a user can only follow an artist once, and can only mark themselves as going to a show once. The runtime rejects duplicate relationship creates automatically.


Actions

Follow / unfollow artist

# actions/follow.actions.causet
actions:
  FOLLOW_ARTIST:
    state: user
    input:
      user_id:   { type: string, required: true }
      artist_id: { type: string, required: true }
    preflight:
      lookups:
        - name: artist
          state: artist
          entity_expr: intent.artist_id
      rules:
        - name: reject_self_follow
          when: { expr: "intent.user_id == intent.artist_id" }
          then:
            - op: reject
              code: CANNOT_FOLLOW_SELF
        - name: artist_must_exist
          when: { expr: "artist == null" }
          then:
            - op: reject
              code: ARTIST_NOT_FOUND
    core:
      rules:
        - name: increment_following_count
          when: {}
          then:
            - op: add
              path: /following_count
              value: 1
    side_effects:
      rules:
        - name: emit_followed
          then:
            - op: emit
              event_type: ARTIST_FOLLOWED
              payload:
                user_id:   intent.user_id
                artist_id: intent.artist_id
 
  UNFOLLOW_ARTIST:
    state: user
    input:
      user_id:   { type: string, required: true }
      artist_id: { type: string, required: true }
    core:
      rules:
        - name: decrement_following_count
          when: { expr: "state.following_count > 0" }
          then:
            - op: add
              path: /following_count
              value: -1
    side_effects:
      rules:
        - name: emit_unfollowed
          then:
            - op: emit
              event_type: ARTIST_UNFOLLOWED
              payload:
                user_id:   intent.user_id
                artist_id: intent.artist_id

Design decision: The self-follow check in preflight uses intent.user_id == intent.artist_id. This is the simplest structural check. In a real system, artist IDs and user IDs come from different namespaces and cannot collide — but the check is cheap and makes the intent explicit.

The LOOKUP_FIELD on artist validates that the artist actually exists before following. Without this check, users could follow non-existent artist IDs, polluting the user_following projection with dead references.

Announce show

# actions/show.actions.causet
actions:
  ANNOUNCE_SHOW:
    state: artist
    input:
      artist_id: { type: string, required: true }
      show_id:   { type: string, required: true }
      venue_id:  { type: string, required: true }
      title:     { type: string, required: true }
      date:      { type: string, required: true }
    preflight:
      rules:
        - name: date_must_be_future
          when: { expr: "intent.date < today()" }
          then:
            - op: reject
              code: SHOW_DATE_IN_PAST
    core:
      rules:
        - name: increment_show_count
          when: {}
          then:
            - op: add
              path: /show_count
              value: 1
    side_effects:
      rules:
        - name: emit_announced
          then:
            - op: emit
              event_type: SHOW_ANNOUNCED
              payload:
                show_id:   intent.show_id
                artist_id: intent.artist_id
                venue_id:  intent.venue_id
                title:     intent.title
                date:      intent.date

Mark going

# actions/attendance.actions.causet
actions:
  MARK_GOING:
    state: user
    input:
      user_id: { type: string, required: true }
      show_id: { type: string, required: true }
    preflight:
      lookups:
        - name: show
          state: show
          entity_expr: intent.show_id
      rules:
        - name: show_must_exist
          when: { expr: "show == null" }
          then:
            - op: reject
              code: SHOW_NOT_FOUND
        - name: show_must_be_announced
          when: { expr: "show.status != 'announced'" }
          then:
            - op: reject
              code: SHOW_NOT_AVAILABLE
    side_effects:
      rules:
        - name: emit_going
          then:
            - op: emit
              event_type: USER_MARKED_GOING
              payload:
                user_id: intent.user_id
                show_id: intent.show_id
 
  CHECK_IN:
    state: user
    input:
      user_id: { type: string, required: true }
      show_id: { type: string, required: true }
    preflight:
      lookups:
        - name: show
          state: show
          entity_expr: intent.show_id
      rules:
        - name: show_must_exist
          when: { expr: "show == null" }
          then:
            - op: reject
              code: SHOW_NOT_FOUND
    core:
      rules:
        - name: increment_checkin_count
          when: {}
          then:
            - op: add
              path: /checkin_count
              value: 1
    side_effects:
      rules:
        - name: emit_checked_in
          then:
            - op: emit
              event_type: USER_CHECKED_IN
              payload:
                user_id:   intent.user_id
                show_id:   intent.show_id
                artist_id: show.artist_id
                venue_id:  show.venue_id
        - name: emit_memory_created
          then:
            - op: emit
              event_type: CONCERT_MEMORY_CREATED
              payload:
                user_id:    intent.user_id
                show_id:    intent.show_id
                artist_id:  show.artist_id
                venue_id:   show.venue_id
                checked_in: true

Design decision: CHECK_IN emits both USER_CHECKED_IN and CONCERT_MEMORY_CREATED in side_effects. These are semantically different events:

  • USER_CHECKED_IN is a fact about attendance — used by show_attendance and artist_popularity projections
  • CONCERT_MEMORY_CREATED is a higher-level event that belongs to the user’s memory model

Separating them lets different projections listen to the event most appropriate for their semantics. The user_concert_memory projection cares about memory creation, not raw check-ins.

Submit review

# actions/review.actions.causet
actions:
  SUBMIT_REVIEW:
    state: user
    input:
      user_id:   { type: string, required: true }
      show_id:   { type: string, required: true }
      artist_id: { type: string, required: true }
      rating:    { type: int, required: true }
      notes:     { type: string, required: false }
    preflight:
      rules:
        - name: rating_must_be_valid
          when: { expr: "intent.rating < 1 || intent.rating > 5" }
          then:
            - op: reject
              code: INVALID_RATING
    side_effects:
      rules:
        - name: emit_review
          then:
            - op: emit
              event_type: REVIEW_SUBMITTED
              payload:
                user_id:   intent.user_id
                show_id:   intent.show_id
                artist_id: intent.artist_id
                rating:    intent.rating
                notes:     intent.notes

Projections

user_concert_memory

Records a user’s concert history — one row per show they attended or created a memory for.

# projections/concert_memory.projections.causet
projections:
  user_concert_memory:
    source_events: [CONCERT_MEMORY_CREATED, REVIEW_SUBMITTED]
    target:
      table: user_concert_memory
      primary_key: [user_id, show_id]
    indexes:
      - fields: [user_id]
    fields:
      user_id:        TEXT
      show_id:        TEXT
      artist_id:      TEXT
      venue_id:       TEXT
      checked_in:     BOOLEAN
      has_review:     BOOLEAN
      rating:         INTEGER
      notes:          TEXT
      memory_at:      BIGINT
      last_updated:   BIGINT
    derive:
      user_id:        event.user_id
      show_id:        event.show_id
      artist_id:      event.artist_id
      venue_id:       event.venue_id
      last_updated:   event.ts
    mutations:
      CONCERT_MEMORY_CREATED:
        op: upsert
        set:
          checked_in: event.checked_in
          has_review: false
          memory_at:  event.ts
      REVIEW_SUBMITTED:
        op: upsert
        set:
          has_review: true
          rating:     event.rating
          notes:      event.notes

Design decision: REVIEW_SUBMITTED only updates the review fields — it does not overwrite checked_in or memory_at. Mutation-specific set: blocks allow partial updates to the row. The last_updated field is derived from event.ts by both mutations.

artist_popularity

Tracks follower and check-in counts per artist. Drives artist ranking.

# projections/artist_popularity.projections.causet
projections:
  artist_popularity:
    source_events: [ARTIST_FOLLOWED, ARTIST_UNFOLLOWED, USER_CHECKED_IN]
    target:
      table: artist_popularity
      primary_key: [artist_id]
    indexes:
      - fields: [follower_count]
        order: desc
    fields:
      artist_id:     TEXT
      follower_count: INTEGER
      checkin_count:  INTEGER
      last_updated:  BIGINT
    derive:
      artist_id:    event.artist_id
      last_updated: event.ts
    mutations:
      ARTIST_FOLLOWED:
        op: upsert
        increment:
          follower_count: 1
      ARTIST_UNFOLLOWED:
        op: upsert
        increment:
          follower_count: -1
      USER_CHECKED_IN:
        op: upsert
        increment:
          checkin_count: 1

Design decision: increment: operations are atomic — the projection worker issues UPDATE ... SET follower_count = follower_count + 1. This avoids read-modify-write races when multiple events for the same artist arrive concurrently.

The index on follower_count DESC supports the “top artists” query without a full table scan.

show_attendance

Tracks how many users marked going and checked in per show.

# projections/show_attendance.projections.causet
projections:
  show_going:
    source_events: [USER_MARKED_GOING]
    target:
      table: show_going
      primary_key: [user_id, show_id]
    indexes:
      - fields: [show_id]
    fields:
      user_id:   TEXT
      show_id:   TEXT
      marked_at: BIGINT
    derive:
      user_id:   event.user_id
      show_id:   event.show_id
      marked_at: event.ts
    mutations:
      USER_MARKED_GOING: { op: upsert }
 
  show_checkins:
    source_events: [USER_CHECKED_IN]
    target:
      table: show_checkins
      primary_key: [user_id, show_id]
    indexes:
      - fields: [show_id]
    fields:
      user_id:      TEXT
      show_id:      TEXT
      artist_id:    TEXT
      checked_in_at: BIGINT
    derive:
      user_id:       event.user_id
      show_id:       event.show_id
      artist_id:     event.artist_id
      checked_in_at: event.ts
    mutations:
      USER_CHECKED_IN: { op: upsert }

Design decision: show_going and show_checkins are separate projections rather than a single show_attendance table. A user can be going without checking in, and may check in without having marked going first (e.g., they showed up unplanned). Separate tables make these two states independently queryable.

user_following

The follow graph — which artists each user follows. Used as a join table for show discovery.

# (in projections/artist_popularity.projections.causet or a dedicated file)
projections:
  user_following:
    source_events: [ARTIST_FOLLOWED, ARTIST_UNFOLLOWED]
    target:
      table: user_following
      primary_key: [user_id, artist_id]
    indexes:
      - fields: [user_id]
    fields:
      user_id:     TEXT
      artist_id:   TEXT
      followed_at: BIGINT
    derive:
      user_id:     event.user_id
      artist_id:   event.artist_id
      followed_at: event.ts
    mutations:
      ARTIST_FOLLOWED:   { op: upsert }
      ARTIST_UNFOLLOWED: { op: delete }

artist_show_directory

The canonical list of all announced and imported shows.

projections:
  artist_show_directory:
    source_events: [SHOW_ANNOUNCED, SHOW_IMPORTED]
    target:
      table: artist_show_directory
      primary_key: [show_id]
    indexes:
      - fields: [artist_id]
      - fields: [date]
    fields:
      show_id:    TEXT
      artist_id:  TEXT
      venue_id:   TEXT
      title:      TEXT
      date:       TEXT
      source:     TEXT
      created_at: BIGINT
    derive:
      show_id:    event.show_id
      artist_id:  event.artist_id
      venue_id:   event.venue_id
      title:      event.title
      date:       event.date
      created_at: event.ts
    mutations:
      SHOW_ANNOUNCED:
        op: upsert
        set:
          source: "announced"
      SHOW_IMPORTED:
        op: upsert
        set:
          source: event.source

friend_activity_feed

A per-user activity feed showing what their followed artists are doing. This is a fan-out projection — one event creates multiple rows (one per user who follows the artist).

# projections/friend_activity.projections.causet
projections:
  friend_activity_feed:
    source_events: [SHOW_ANNOUNCED, USER_CHECKED_IN, REVIEW_SUBMITTED]
    target:
      table: friend_activity_feed
      primary_key: [feed_key]
    indexes:
      - fields: [user_id, occurred_at]
        order: desc
    fields:
      feed_key:    TEXT   # "{user_id}:{event_id}" — unique per feed entry
      user_id:     TEXT   # the feed owner (not the actor)
      actor_id:    TEXT   # the user/artist who did the thing
      event_type:  TEXT
      show_id:     TEXT
      artist_id:   TEXT
      occurred_at: BIGINT
    derive:
      feed_key:    concat(state.user_id, ":", event.event_id)
      user_id:     state.user_id   # resolved from user_following join
      actor_id:    event.user_id
      event_type:  event.event_type
      show_id:     event.show_id
      artist_id:   event.artist_id
      occurred_at: event.ts
    fan_out:
      from: user_following
      join_on: user_following.artist_id = event.artist_id
      yield: user_following.user_id as state.user_id
    mutations:
      SHOW_ANNOUNCED:   { op: upsert }
      USER_CHECKED_IN:  { op: upsert }
      REVIEW_SUBMITTED: { op: upsert }

Design decision: Fan-out projections create one row in the feed per follower of the artist. For artists with millions of followers, this fan-out can be very large. Consider:

  • Limiting feed size per user (TTL on old entries, or only keeping the last N entries)
  • Using a pull model instead (query artist_show_directory joined with user_following at read time)

The pull model (query-time join) is simpler and avoids fan-out write amplification. The push model (fan-out projection) gives faster reads but higher write cost. For most applications, start with the query-time join.


Queries

shows_for_followed_artists

Show discovery: what shows are coming up for artists a user follows.

# queries/shows.queries.causet
queries:
  shows_for_followed_artists:
    from: artist_show_directory
    joins:
      user_following:
        on:
          artist_show_directory.artist_id: user_following.artist_id
        fields:
          - user_following.user_id
    fields:
      - artist_show_directory.show_id
      - artist_show_directory.artist_id
      - artist_show_directory.title
      - artist_show_directory.venue_id
      - artist_show_directory.date
    where:
      user_following.user_id: { eq: input.user_id }
      artist_show_directory.date: { gte: input.after_date }
    input:
      user_id:    { type: string, required: true }
      after_date: { type: string, required: false, default: "today" }
    order_by:
      artist_show_directory.date: asc
    limit: 50

artist_stats

Aggregate follower and check-in counts for an artist profile page.

# queries/artist.queries.causet
queries:
  artist_stats:
    from: artist_popularity
    fields:
      - artist_id
      - follower_count
      - checkin_count
    where:
      artist_id: { eq: input.artist_id }
    input:
      artist_id: { type: string, required: true }

my_upcoming_shows

Shows a user has marked going to that haven’t happened yet.

# queries/user.queries.causet
queries:
  my_upcoming_shows:
    from: show_going
    joins:
      artist_show_directory:
        on:
          show_going.show_id: artist_show_directory.show_id
        fields:
          - artist_show_directory.title
          - artist_show_directory.artist_id
          - artist_show_directory.venue_id
          - artist_show_directory.date
    where:
      show_going.user_id: { eq: input.user_id }
      artist_show_directory.date: { gte: today() }
    input:
      user_id: { type: string, required: true }
    order_by:
      artist_show_directory.date: asc
 
  my_concert_memories:
    from: user_concert_memory
    fields:
      - user_id
      - show_id
      - artist_id
      - venue_id
      - checked_in
      - has_review
      - rating
      - memory_at
    where:
      user_id: { eq: input.user_id }
    order_by:
      memory_at: desc
    input:
      user_id: { type: string, required: true }
 
  top_artists:
    from: artist_popularity
    fields:
      - artist_id
      - follower_count
      - checkin_count
    order_by:
      follower_count: desc
    limit: 20

Compile and deploy

# Compile
causet build compile --runtime concert-app --out build/concert-app-out
 
# Expected output
[INFO] Compiled 4 states, 10 events, 5 actions, 8 projections, 5 queries
[INFO] Output: build/concert-app-out/causet.runtime.json
[INFO] Output: build/concert-app-out/causet.projections.json

Example: intent flow

Follow an artist, announce a show, and query followed artists using @causet/sdk-node:

import { createCausetClient } from '@causet/sdk-node';
 
const client = createCausetClient({
  apiUrl: process.env.CAUSET_API_URL ?? 'http://localhost:8085',
  platformSlug: 'jamlet',
  appSlug: 'concert-app',
  forkId: 'main',
  apiKey: process.env.CAUSET_API_KEY,
});
 
await client.init();
 
// Follow an artist
const follow = await client.emit('user_stream', 'user-alice', 'FOLLOW_ARTIST', {
  user_id: 'user-alice',
  artist_id: 'artist-pearl-jam',
});
console.log(follow.accepted, follow.executionId);
 
// Announce a show
await client.emit('artist_stream', 'artist-pearl-jam', 'ANNOUNCE_SHOW', {
  artist_id: 'artist-pearl-jam',
  show_id: 'show-pj-nyc-2026-09',
  venue_id: 'venue-barclays-center',
  title: 'Pearl Jam – Dark Matter Tour',
  date: '2026-09-15',
});
 
// Query shows for followed artists
const { items } = await client.runQuery('shows_for_followed_artists', {
  user_id: 'user-alice',
});
 
client.destroy();

Expected query response:

{
  "items": [
    {
      "show_id": "show-pj-nyc-2026-09",
      "artist_id": "artist-pearl-jam",
      "title": "Pearl Jam – Dark Matter Tour",
      "venue_id": "venue-barclays-center",
      "date": "2026-09-15"
    }
  ]
}

The same pattern powers the interactive demos — a thin causet-adapter.mjs wraps CausetClient for emit() and runQuery() in Express or Next.js route handlers.


Design decisions summary

DecisionRationale
Separate CONCERT_MEMORY_CREATED from USER_CHECKED_INMemory is a higher-level concept. Check-in is a physical fact. Projections subscribe to the right semantic event.
user_following as a delete-on-unfollow projectionKeeps the projection table current without needing a “following?” query to check.
artist_popularity uses increment:Atomic increments avoid read-modify-write races under concurrent events for the same artist.
Separate show_going and show_checkins tablesUsers can check in without marking going (walk-up attendees). Two states, two tables.
Pull model for show discovery (join at query time)Avoids fan-out write amplification for artists with large followings. Simpler to maintain.
Fan-out projection for activity feedPush model for feed; acceptable for small-to-medium followings. Switch to pull if write amplification becomes a bottleneck.