High Level Design

Components

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

Responsibilities

ComponentOwnsNever does
ClientThe learner's experience; local truth for their own actions; content verificationTrust unsigned content; block on the network
WorkerRouting, edge caching, link previews, rate limits, content publishingHold user data
APIAccounts and sessions; applying sync mutations exactly once; the change feed; sharingServe content or the app
Jobs processLearner models, reminders, retention, lake export, content mirrorServe HTTP traffic
PostgresThe system of record for everything syncedHold content
R2Content (immutable, hashed) and the analytics lake—

Request paths

Opening the app

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.

Learning and syncing

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

Publishing content

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

Background work

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.

Deployment

PieceWhereHow it ships
Website and WorkerCloudflare, globalCI on merge to main → wrangler deploy, source maps to Sentry
API and jobsFly.io, Singapore: 2 app machines, 1 worker, 1 standbyCI → remote Docker build → migrations as release command → rolling deploy
ContentR2 and the edge cacheCI → build, sign, upload changed files, switch the manifest
iOS / AndroidTestFlight and Firebase App Distribution today; stores nextTagged build in CI, or from a workstation
DatabaseSupabase PostgresMigration files applied by the release command

Environments

  • Production: schrollar.com.
  • Preview content channel: a tester's device can read the next content publish before everyone else (tap the version on the Me screen five times). Content flows preview → promote → live.
  • Local: the API runs on an embedded Postgres (PGlite) when no database URL is set; the web dev server serves locally built content.
  • Staging API: planned; see Roadmap.

Cross-cutting choices

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.

  • Idempotency everywhere. Every mutation has a client-generated ULID that the server records before applying it, so retries are always safe.
  • Immutable, content-addressed files. Only the small manifest changes between publishes; everything else is cacheable forever.
  • Backwards compatible by design. Old app builds keep working: unknown fields are ignored, new mutation types are additive, boxes needing a newer engine are hidden, and a signed min build can ask very old apps to update.

${sch.nav()}