One trip through the whole system¶
The fastest way to understand this codebase is to follow a single action all the way down. This is what happens between somebody tapping Publish to the circle (Publicar en el círculo on a phone set to Spanish) and their friends being able to watch, touching seven layers on the way. Terms in bold are in the glossary.
Everything before this point — the draw, the recording, the edit — is covered where it happens below.
0. Before the button exists¶
By the time anyone can publish, three things have already happened.
The draw. A scheduler tick found a circle whose turn had ended, or one that
had never had a turn at all, and picked a vlogger
(app/services/turns.py::tick → app/services/draw.py). The weighting favours
whoever has had fewest turns and excludes the previous vlogger unless they are
the only candidate. The circle gets a push notification.
The recording. app/record/[turnId].tsx. Each take is written to the phone's
sandbox and appended to the manifest (src/storage/clipVault.ts). Nothing is
uploaded and nothing touches the server, so somebody can record on Monday and
finish on Wednesday. Flipping the camera closes one segment and opens
another inside the same take.
The edit. src/edit/ builds an EDL from the manifest, chooses an
engine (engine.ts), and renders. On Android that is the native module:
decode → OpenGL → re-encode, one encoder for the whole timeline. The result is
one mp4 in the app's sandbox, and the review screen plays it.
1. The tap¶
app/review/[turnId].tsx → publishVlog() in src/edit/publish.ts.
The publish button deliberately sits above the editing controls. The premise of the product is that nobody wants to edit, so the default path has to be the shortest one; reordering and deleting are there for when something is wrong, not as a step everyone walks through.
2. Asking permission to upload¶
POST /turns/{id}/upload-url → app/api/turns.py, carrying the file's
size_bytes and its content_type (which can only be video/mp4).
The server checks the caller is the vlogger and the turn is still open, and
refuses with 413 anything over MAX_UPLOAD_BYTES before signing anything. It
works out the object key (circles/{circle}/vlogs/{turn}.mp4) and returns a
presigned URL — a signed permit that expires. On R2 the signature covers
the content type and the exact content length, so the bucket itself refuses a
different file. It does not accept the file.
It does write two things down: a provisional Vlog row in uploading, which a
retried upload reuses rather than duplicating, and the turn moves to
rendering.
This is the hinge of the whole architecture. If the video went through the API, the machine would need to be big enough for everybody watching a premiere at once, which is the one moment they all arrive together. Instead the server authorises a transfer it never carries. See the diagram in architecture.md.
3. The upload¶
Back in publish.ts: a plain HTTP PUT from the phone straight to Cloudflare
R2, with the headers the server said to send and nothing else. It streams off
disk through createUploadTask (src/api/fileUpload.ts), with a deadline on
silence rather than on the whole transfer: two minutes without a byte moving
cancels it, however long a slow but healthy upload takes.
Retried with exponential backoff, because this is a phone on mobile data. The retry is safe because the object key is derived from the turn, so a second attempt overwrites the first rather than leaving debris. Only what can go differently is retried: no answer at all, a stall, a 408, a 429 or a 5xx. Any other answer is the bucket's considered opinion and is not asked again — except a 403, which is the signed URL expiring under a slow upload, and gets one fresh ticket from step 2.
If the app dies in the middle, the manifest says uploading, and the next
launch calls /complete first: the PUT may well have finished just before the
app died, and asking costs one small request where re-uploading costs the
whole vlog. Only a 409 sends it back to upload again.
Almost everything that ever goes wrong with R2 goes wrong in the gap between signing and uploading, because the signature covers exactly the headers the client sends.
scripts/r2_check.pyexists to cross that gap on purpose.
4. Telling the server it worked¶
POST /turns/{id}/complete.
The server confirms the object really exists in the bucket — it does not take
the client's word for it — and checks the size and content type of what is
actually there. An object over MAX_UPLOAD_BYTES or not an mp4 is deleted on
the spot and answered with 413 or 415. Then it marks the Vlog row ready,
moves the turn to published, and sets the premiere to end once the circle's premiere_hours (24 by
default) have passed.
Idempotent, deliberately. A phone that uploads, loses signal before the reply and retries must not create a second vlog or restart the premiere.
5. Everybody else finds out¶
The push goes to every member of the circle except the vlogger, except
whoever has muted this circle for themselves, and lands on every device each
of them has signed into — see
Push: one token per device, muted per circle.
Nothing is sent from inside the transaction. The push is queued while the turn
is published, and only once the commit has landed is the queue turned into
messages: muted members dropped, and each remaining recipient's text rendered
in the language on their own account, since one circle can hold both. The
round trip to Expo then runs in the background after the response has gone
back (send_after_commit in app/services/turns.py). Sending is
fire-and-forget: a notification that fails must never stop a vlog from
publishing, and one about a commit that never happened must never go out.
6. Watching¶
app/watch/[turnId].tsx asks the API for the turn, which includes a
playback_url. With a CDN domain configured that is a plain public URL and the
video is served from Cloudflare's edge — the API machine is not involved at all.
Without one it is signed against the bucket instead.
POST /vlogs/{id}/view records that somebody watched, which is what the "who
has seen it" list is built on, and the numbers the vlogger is pushed when the
premiere closes. It is not what ends a premiere early: that takes a re-roll
vote from every active member (votes_needed_for in app/services/voting.py).
From here somebody can also comment — which pushes the vlogger back, the same
way — or pull their own copy onto the device with Save to device.
7. And round again¶
24 hours later a scheduler tick finds the premiere has expired, archives the
turn and draws the next vlogger. The same tick() also hands out strikes for
deadlines that passed with nothing recorded.
tick() is idempotent and runs every 15 minutes in production. Both facts
matter: idempotent because it must survive being run twice, and 15 minutes
because the database sleeps between ticks — see limits.md.
What to read next¶
- gotchas.md — the things that will confuse you, and cost hours if nobody tells you first.
- architecture.md — why the pieces are arranged this way.
- decisions.md — why they are not arranged some other way.