flowchart TB
subgraph DEV["Devices"]
direction LR
PWA["Browser (PWA)<br/>service worker caches the app"]
NAT["iOS / Android (Capacitor)<br/>app code in the binary"]
LOCAL[("IndexedDB<br/>keeps · reviews · decks · outbox<br/>verified content cache")]
PWA --- LOCAL
NAT --- LOCAL
end
subgraph EDGE["Cloudflare Worker · schrollar.com"]
direction LR
ASSETS["/ · app shell and assets"]
PREV["/b/ · /play/ · /s/<br/>link previews"]
CONT["/content/*<br/>manifest 60 s · hashed files immutable"]
PUB["/_content/*<br/>publish API (token)"]
PROXY["/api/*<br/>per-IP limit → proxy"]
end
subgraph STORE["Cloudflare R2"]
R2C[("content")]
R2L[("Parquet lake")]
end
subgraph FLY["Fly.io · Singapore"]
APP["app ×2<br/>Hono API"]
WRK["worker ×1<br/>leased jobs"]
REL["release command<br/>migrations"]
end
PG[("Postgres<br/>system of record")]
EXT["APNs · FCM · Web Push<br/>Resend · Sentry · Google sign-in"]
DEV -->|HTTPS| EDGE
CONT & PUB --> R2C
PROXY -->|HTTPS + shared secret| APP
APP --> PG
WRK --> PG
REL --> PG
WRK --> R2L
APP & WRK --> EXT
| Component | Owns | Never does |
|---|---|---|
| Client | The learner's experience; local truth for their own actions; content verification | Trust unsigned content; block on the network |
| Worker | Routing, edge caching, link previews, rate limits, content publishing | Hold user data |
| API | Accounts and sessions; applying sync mutations exactly once; the change feed; sharing | Serve content or the app |
| Jobs process | Learner models, reminders, retention, lake export, content mirror | Serve HTTP traffic |
| Postgres | The system of record for everything synced | Hold content |
| R2 | Content (immutable, hashed) and the analytics lake | — |
sequenceDiagram
autonumber
participant D as Device
participant W as Worker (edge)
participant R as R2
D->>W: GET / (or from the service worker / app bundle)
D->>W: GET /content/manifest.json
W->>R: on edge-cache miss
W-->>D: signed manifest (cached 60 s)
Note over D: verify Ed25519 signature<br/>with keys pinned in the build
D->>W: GET catalog, taxonomy, puzzles (hashed file names)
W-->>D: immutable files (edge cache, ~55 ms)
Note over D: check each SHA-256, cache on device,<br/>hide boxes this build can't play
Startup never waits for the API. Offline, the last verified manifest and cached files are used.
Every action writes to IndexedDB and appends a mutation to the outbox in one transaction; the UI updates from the local database at once. The sync engine pushes in the background and pulls only when another device wrote something. Details in Sync Engine.
sequenceDiagram
autonumber
participant D as Device
participant A as API
participant P as Postgres
D->>A: POST /v1/sync/push (≤200 mutations, user data first)
A->>P: claim ids in sync_mutations, apply, append to change feed
A-->>D: accepted · rejected · head
alt head > cursor, app opened or resumed, or 5 minutes passed
D->>A: GET /v1/sync/pull?cursor=n
A-->>D: other devices' changes
end
flowchart LR
Y[YAML in git] --> V[validate] --> B[build<br/>compile · hash · sign] --> U["upload changed files<br/>via /_content"] --> M[switch manifest last]
M -.->|rollback: point manifest<br/>at an earlier build| M
The jobs process ticks every minute and runs whatever is due, holding a lease row so no job ever runs twice at once. See Background Jobs.
| Piece | Where | How it ships |
|---|---|---|
| Website and Worker | Cloudflare, global | CI on merge to main → wrangler deploy, source maps to Sentry |
| API and jobs | Fly.io, Singapore: 2 app machines, 1 worker, 1 standby | CI → remote Docker build → migrations as release command → rolling deploy |
| Content | R2 and the edge cache | CI → build, sign, upload changed files, switch the manifest |
| iOS / Android | TestFlight and Firebase App Distribution today; stores next | Tagged build in CI, or from a workstation |
| Database | Supabase Postgres | Migration files applied by the release command |
preview → promote → live.decision Shared code, same answers. The client and server import the same packages for scheduling, experiments and the protocol, so they never disagree about a due date or a variant.
min build can ask very old apps to update.${sch.nav()}