Data model¶
Ten 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
VLOGS ||--o{ VLOG_REACTIONS : collects
USERS ||--o{ VLOG_REACTIONS : leaves
VLOGS ||--o{ VLOG_COMMENTS : collects
USERS ||--o{ VLOG_COMMENTS : writes
USERS ||--o{ PUSH_TOKENS : registers
USERS {
uuid id PK
string email UK
string display_name
string password_hash
int token_version "bumped to end every session at once"
string reset_code_hash "a password reset in flight, hashed"
datetime reset_code_expires_at
bool email_verified "informational only -- nothing gates on it"
string email_verify_code_hash "a verification code in flight, hashed"
datetime email_verify_code_expires_at
string language "es or en: what pushes and emails are written in"
datetime created_at
datetime deleted_at "set when the account is closed"
}
PUSH_TOKENS {
uuid id PK
uuid user_id FK
string token UK "one Expo token, one device install"
datetime created_at
datetime updated_at
}
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 "counted and shown; they remove nobody"
int turns_taken "what the draw weights against"
bool muted "silences this member's own pushes for this circle only"
datetime joined_at "decides who inherits the circle"
}
TURNS {
uuid id PK
uuid circle_id FK
uuid vlogger_id FK
string status "see below"
datetime starts_at
datetime deadline_at
datetime reminded_at "the deadline reminder went out"
int clip_count "last heartbeat: takes on the phone"
float recorded_seconds "last heartbeat: footage on the phone"
datetime published_at
datetime premiere_ends_at
datetime created_at "with id, the key the history pages by"
}
VLOGS {
uuid id PK
uuid turn_id FK "unique: one vlog per turn"
string object_key "circles/{id}/vlogs/{turn}.mp4"
string status "uploading or ready; failed is unused"
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
}
VLOG_REACTIONS {
uuid id PK
uuid vlog_id FK
uuid user_id FK
string emoji "one of five; the set lives in the API"
}
VLOG_COMMENTS {
uuid id PK
uuid vlog_id FK "indexed"
uuid user_id FK
string body "200 characters at most, trimmed"
datetime created_at
}
Seven things about this shape are deliberate:
A push token belongs to a device install, not to a user. users held one
token until #118: a second phone or a tablet opened on the same account simply
evicted whoever registered first, and reinstalling the app did the same to
whatever was there before. push_tokens is its own table so an account can
hold as many live tokens as it has devices, and token is unique across the
whole table rather than per user — a token can only ever reach the one install
that holds it, so if it turns up on a second account it has moved there, not
been copied.
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. joined_at is what an owner's departure is settled
by: the circle goes to the active member who has been in it longest.
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.
A deleted account keeps its row. deleted_at is set and everything that
identifies the person is scrubbed — address, name, password, every registered
push token — but the row stays, because turns and vlogs point at it. A
circle's history is not one member's to erase: the vlogs were made for the
group and the group is still watching them. Nothing may sign in afterwards.
The token check refuses a deleted account outright (on top of the
token_version bump that already ended its sessions), and a password reset
or a new verification code is never sent to one. The login has no check of
its own: it fails because password_hash is set to "deleted", which is not
a hash of anything, so no password verifies against it. The name is blanked
too; what the circle sees instead is chosen per response, in the reader's
language (#157).
A reaction is one row per emoji per person. Reacting is a toggle rather
than a choice between feelings, so somebody can leave two. Which emoji exist is
a product decision that will change, so it lives in REACTIONS in
app/schemas/schemas.py rather than in a check constraint that would need a
migration every time it moved.
A comment is a row of its own, short on purpose. vlog_comments holds one
row per comment, at most 200 characters (COMMENT_MAX_LENGTH), trimmed before
the length is checked so a comment of only spaces is refused. It sits next to
the reactions rather than replacing them -- a second quick "I saw this", not a
place for a thread -- and anyone still in the circle can leave one, before or
after the premiere closes; the vlogger is pushed about it unless it is their
own. Added in migration 445d8ee88168, with its index on vlog_id in
cfd3a0749122.
One declared value is not used: VlogStatus.FAILED exists in
app/models/enums.py, but nothing assigns it -- a vlog row is uploading until
the upload is confirmed and ready after. Whether it goes or starts being
used is #170.
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 take saved
pending --> rendering : edit or upload started
recording --> rendering : edit or upload started
rendering --> recording : server render failed or busy
rendering --> published : uploaded and confirmed
published --> archived : premiere_hours run out
pending --> missed : deadline passed, or vlogger left
recording --> missed : deadline passed, or vlogger left
rendering --> missed : deadline passed, or vlogger left
pending --> skipped : majority votes to re-roll
recording --> skipped : majority votes to re-roll
published --> archived : everyone votes to end early
missed --> [*] : redraw at once
skipped --> [*] : no strike, redraw at once
archived --> [*] : redraw
note right of missed
deadline passed: a strike
vlogger left the circle: none
end note
What moves a turn between the open states is the vlogger's phone, not a
rule on the server. A heartbeat sets recording or rendering to whatever the
phone says it is doing, so it can move either way; asking for an upload URL
and staging a take for the server render both set rendering; and a server
render that fails or finds the server busy puts the turn back to recording,
so the vlogger can try again (app/api/turns.py). The tick treats all three
open states the same: past the deadline, each is missed.
missed and skipped look alike and are not. Both hand the turn to
somebody else immediately. A deadline that passes with nothing published is
missed with a strike, whether or not there was footage on the phone -- the
server cannot see it, and the deal was to publish. A re-roll is skipped and
never carries one: that is the group deciding, not a failure. The third way in
is the vlogger leaving the circle, being removed, or deleting the account
while the turn is open: the turn becomes missed without a strike and the
roulette spins again at once (remove_from_circle in
app/services/members.py), because leaving is not the same as being given a
turn and dropping it. A turn already premiering is left to run.
The states before published are visible to the whole circle. Every
member gets the turn's status and the last heartbeat's clip_count and
recorded_seconds (TurnOut in app/schemas/schemas.py), and the circle
screen shows them: "they are recording, five takes, 2:10", "they are editing
the vlog". It is the only sign of life a turn gives off before the vlog
exists. What nobody but the vlogger can see is the footage itself.
A premiere lasts the circle's premiere_hours. 24 by default, anything
from 1 to 168 that the owner sets; premiere_ends_at is fixed from it at the
moment of publishing.
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.
A circle has at most one open turn, and the database says so. The partial
unique index uq_turns_one_open covers circle_id over pending,
recording and rendering only. draw_new_turn still checks first, but two
requests can pass that check together (a pair of re-roll votes, a forced draw
racing the tick); the second insert now fails, and the draw treats that as
"somebody already drew" instead of opening a second turn (#142). When a
re-roll vote loses that race, _redraw_or_stand_down drops the "turn skipped"
push it had queued and answers with the turn the winner opened, so the circle
hears it once. The migration that adds the index (e5b7c1d9f042) first
settles any circle already holding more than one open turn: the newest stays
and the rest become skipped -- no strike, since nobody should be punished
for a turn the server should never have opened.
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, kept on the turn — so a circle can be told somebody is recording without any of it being uploaded. That is the only sign of life a turn gives off before the vlog exists, and for everybody but the vlogger it is the difference between a countdown and something happening.