Skip to content

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.