Skip to main content

Architecture overview

Three deployables and one shared contract.

The one decision everything else follows from

Screens read local SQLite. They never call the API.

That is not an optimisation — it is the shape of the product. Someone standing under a wet gritstone edge with no signal should get exactly the same app as someone on office wifi. Once you accept that, most of the rest is forced:

  • The phone needs its own database, not a cache → expo-sqlite
  • Writes made offline need somewhere to wait → an outbox table
  • The server needs to answer "what changed since X?" → cursor-based delta sync
  • Deletes have to be visible to a device that was offline when they happened → tombstones, never hard deletes
  • IDs have to be mintable on the phone → client-generated ULIDs, not autoincrement

Details in Offline-first and ADR-0004.

What runs where

PieceWhereWhy there
APICloudflare Worker, apps/apiFast cold starts anywhere; the app is used in places with bad latency, and a Worker is close to all of them
DatabaseD1, bound to the WorkerIt is SQLite, which is also what the phone runs — one mental model, one SQL dialect, one set of migrations to reason about
Mobile appExpo / React Native, apps/mobileSee ADR-0003
DocsCloudflare Pages, built from apps/docsSame account, same deploy pipeline, preview URLs per PR
Shared contractpackages/shared, consumed as sourceZod schemas that both sides import, so a drift is a type error rather than a runtime surprise

Request path

A read, once a crag has been downloaded:

A sync, when there is signal:

Push before pull, deliberately: the outbox is the user's own work, and losing it to a conflicting server copy is much worse than showing stale crag data for another thirty seconds.

The shared contract

packages/shared is imported as TypeScript source by both sides — no build step, no published package, no version skew. It holds:

  • Domain schemas (domain.ts) — Crag, Sector, Photo, Route, RouteLine, Ascent, WishlistItem
  • Grade handling (grades.ts) — the systems, and the lossy normalisation used only for filtering
  • ID minting (ids.ts) — prefixed ULIDs, generatable offline
  • The sync protocol (sync.ts) — request and response shapes, cursor encoding
  • The REST contract (api.ts)

The Worker validates every request body against these schemas; the app validates every response. A backend that drifts fails at the seam with a clear message rather than three screens later.

What is deliberately absent

  • No ORM on the client. Plain SQL. The queries are few and known, and a phone should not pay for a query builder at startup.
  • No global state library. React Query for in-flight server work, SQLite for everything that persists. A third source of truth is a third thing to keep in step.
  • No BFF or GraphQL layer. One client, one API, and a bundle endpoint (/v1/crags/:slug/bundle) for the one case that genuinely needs a compound response.
  • No background sync daemon yet. Sync runs on launch and on demand. Proper background refresh is a real feature with real battery implications and needs its own decision.

Known gaps

Things that are missing and known to be missing — recorded here so nobody has to rediscover them:

GapImpactWhere it is tracked
No accounts. A local device identity the server takes at face valueA logbook lives on one phone; partner tagging blockedADR-0007 — deliberate for v1
No image storage. Photo.storageKey points at an R2 bucket that does not existBlocks the topo viewer, the core featureRoadmap phase 1
Conflicts resolve server-wins, silently. The protocol reports them; the app discardsTwo-device editing loses data quietlyRoadmap phase 2
No rate limiting.A public sync endpoint with no limits is a bad ideaNeeds an ADR before public launch