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
| Piece | Where | Why there |
|---|---|---|
| API | Cloudflare Worker, apps/api | Fast cold starts anywhere; the app is used in places with bad latency, and a Worker is close to all of them |
| Database | D1, bound to the Worker | It is SQLite, which is also what the phone runs — one mental model, one SQL dialect, one set of migrations to reason about |
| Mobile app | Expo / React Native, apps/mobile | See ADR-0003 |
| Docs | Cloudflare Pages, built from apps/docs | Same account, same deploy pipeline, preview URLs per PR |
| Shared contract | packages/shared, consumed as source | Zod 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:
| Gap | Impact | Where it is tracked |
|---|---|---|
| No accounts. A local device identity the server takes at face value | A logbook lives on one phone; partner tagging blocked | ADR-0007 — deliberate for v1 |
No image storage. Photo.storageKey points at an R2 bucket that does not exist | Blocks the topo viewer, the core feature | Roadmap phase 1 |
| Conflicts resolve server-wins, silently. The protocol reports them; the app discards | Two-device editing loses data quietly | Roadmap phase 2 |
| No rate limiting. | A public sync endpoint with no limits is a bad idea | Needs an ADR before public launch |