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