Skip to content

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.