Data model¶
Seven tables, and a state machine over one of them. Terms are in the glossary.
The tables¶
erDiagram
USERS ||--o{ MEMBERSHIPS : "belongs to"
CIRCLES ||--o{ MEMBERSHIPS : has
USERS ||--o{ CIRCLES : owns
CIRCLES ||--o{ TURNS : "spins for"
USERS ||--o{ TURNS : "is vlogger of"
TURNS ||--o| VLOGS : produces
TURNS ||--o{ TURN_REROLL_VOTES : collects
USERS ||--o{ TURN_REROLL_VOTES : casts
VLOGS ||--o{ VLOG_VIEWS : "watched in"
USERS ||--o{ VLOG_VIEWS : watches
USERS {
uuid id PK
string email UK
string display_name
string password_hash
string expo_push_token "null until a device registers"
}
CIRCLES {
uuid id PK
string name
string invite_code UK "8 characters, how you join"
int cadence_days "3, 5 or 7"
int max_total_seconds "length of the finished vlog"
int clip_max_seconds
int premiere_hours
uuid owner_id FK
}
MEMBERSHIPS {
uuid id PK
uuid circle_id FK
uuid user_id FK
string role "owner or member"
bool is_active "leaving does not delete history"
int strikes "three and you are out"
int turns_taken "what the draw weights against"
}
TURNS {
uuid id PK
uuid circle_id FK
uuid vlogger_id FK
string status "see below"
datetime starts_at
datetime deadline_at
datetime published_at
datetime premiere_ends_at
}
VLOGS {
uuid id PK
uuid turn_id FK "unique: one vlog per turn"
string object_key "circles/{id}/vlogs/{turn}.mp4"
string status "uploading, ready, failed"
float duration_seconds
int size_bytes
int clip_count
string render_notes "which engine, and what it could not do"
}
TURN_REROLL_VOTES {
uuid id PK
uuid turn_id FK
uuid user_id FK
}
VLOG_VIEWS {
uuid id PK
uuid vlog_id FK
uuid user_id FK
}
Three things about this shape are deliberate:
A vlog belongs to a turn, not to a user. The turn is the unit the whole product revolves around; who recorded it is a property of the turn.
Leaving a circle deactivates a membership rather than deleting it. Vlogs somebody published stay, and so does the record that they were there. It also means a vote from someone who has left stops counting without their vote row having to be hunted down.
render_notes is on the vlog. It says which engine produced it and what
that engine could not do — the only way, after the fact, to tell a vlog made on
the phone from one the server had to rescue.
Turn states¶
The product is a state machine over one row. Everything else — the draw, the strikes, the votes, the premiere — is a transition here.
stateDiagram-v2
[*] --> pending : draw
pending --> recording : first clip saved
recording --> rendering : window closed, phone exporting
rendering --> published : uploaded and confirmed
published --> archived : 24 h premiere ends
pending --> missed : deadline, nothing recorded
recording --> missed : deadline, nothing published
pending --> skipped : majority votes to re-roll
recording --> skipped : majority votes to re-roll
published --> archived : everyone votes to end early
missed --> [*] : strike, redraw at once
skipped --> [*] : no strike, redraw at once
archived --> [*] : redraw
missed and skipped look alike and are not. Both hand the turn to
somebody else immediately. Only missed carries a strike, because only one of
them is a failure — the other is the group deciding.
Only published is visible to anyone but the vlogger. The states before it
are their own progress, and the app shows the circle nothing but "it is their
turn" until the vlog exists.
Ending a premiere early needs everybody, not a majority. It takes the vlog away from anyone who has not watched, so the people who would lose it have to agree.
What lives on the phone instead¶
Not everything is in Postgres. While a turn is open, the footage and its running
order exist only on the vlogger's device, in a JSON manifest beside the
files (src/storage/clipVault.ts).
That is what makes it possible to record on Monday and publish on Wednesday without the server holding half a vlog. It also means an uninstalled app loses unpublished footage, which is the intended trade: the server never holds anything the group has not seen.
The server learns about progress through heartbeats — clip count and total seconds — so a circle can be told somebody is recording without any of it being uploaded.