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.causetApp 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.causetEntities (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: 0Events
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: stringShow 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: stringMemory 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: stringRelationships
# 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_GOINGThe 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_idDesign 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.dateMark 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: trueDesign decision: CHECK_IN emits both USER_CHECKED_IN and CONCERT_MEMORY_CREATED in side_effects. These are semantically different events:
USER_CHECKED_INis a fact about attendance — used byshow_attendanceandartist_popularityprojectionsCONCERT_MEMORY_CREATEDis 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.notesProjections
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.notesDesign 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: 1Design 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.sourcefriend_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_directoryjoined withuser_followingat 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: 50artist_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: 20Compile 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.jsonExample: 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
| Decision | Rationale |
|---|---|
Separate CONCERT_MEMORY_CREATED from USER_CHECKED_IN | Memory 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 projection | Keeps 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 tables | Users 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 feed | Push model for feed; acceptable for small-to-medium followings. Switch to pull if write amplification becomes a bottleneck. |