ADR-0002 — Cloudflare Workers and D1
| Date | 2026-08-17 |
| Superseded by | — |
Context
The backend has a narrow job: serve read-mostly guidebook content, and accept a small stream of user-owned writes. The client is offline-first, so the API is not on the critical path of any screen.
What it does need:
- Low cold-start latency, everywhere. The app is used in places with bad connectivity; adding a 900ms Lambda cold start to a request that already took two seconds to establish is unnecessary.
- A per-PR preview, with its own database, deployable from CI in under a minute. That is a hard requirement, not a nicety — see ADR-0006.
- Near-zero cost at zero users, because there are currently zero users.
- SQLite semantics, because the phone runs SQLite and having one dialect and one mental model across both sides is worth a lot.
Decision
The API is a Hono app on Cloudflare Workers, with Cloudflare D1 as the database.
- Hono for routing, with
@hono/zod-validatorbinding the shared Zod schemas to request validation. Small, Workers-native, no Node polyfill needed. - D1 bound as
DB. It is SQLite — the same engine asexpo-sqliteon the phone. - Drizzle for queries and for generating migrations from
src/db/schema.ts. Migrations are applied withwrangler d1 migrations apply, neverdrizzle-kit push— D1 is the authority on what has run. - Three environments in
wrangler.jsonc:local(Miniflare),preview,production, each with its own D1 database. - Types come from
wrangler types, not@cloudflare/workers-types. The generatedworker-configuration.d.tsderivesCloudflare.Envfrom the actual bindings, so a binding that is not configured is a type error. It is gitignored and regenerated by thetypecheckscript.
Consequences
What gets harder:
- D1 has limits. Database size, query duration, and rows-read per query are all bounded. Fine for guidebook data; it would not be fine for something write-heavy or analytical.
- No connection-level transactions across requests. D1 supports batches and single-statement transactions; anything needing a long-lived transaction has to be redesigned. The sync push loop is written per-mutation partly because of this.
- Workers runtime, not Node. No filesystem, no long-running processes, no arbitrary npm
package that assumes Node.
nodejs_compatcovers a lot but not everything. - Cold data is slower than the marketing suggests. D1 reads from the primary region; a request from a Worker in Sydney to a database in Europe is not free. Read replication exists and we are not using it yet.
What we are committed to:
- Cloudflare. Not catastrophically — Hono runs on several runtimes and D1 is SQLite, so the exit is "export the SQLite file, redeploy the Hono app elsewhere". But the preview-deployment workflow, the bindings model, and the migration tooling are all Cloudflare-shaped.
What this creates:
- Two D1 databases to keep migrated (
crag-topo-db,crag-topo-db-preview), both applied from CI. - A
migrationsCI job that applies every migration to a throwaway local D1 on each PR, so a broken migration fails before it reaches a real database.
Alternatives considered
Postgres on a managed host (Supabase, Neon, RDS)
More capable in every dimension: real transactions, real joins at scale, extensions, no row-read limits. Rejected for this project because the workload does not need any of it, and the per-PR-database requirement gets meaningfully harder and more expensive. Revisit if the data model grows a genuinely relational problem — moderation queues and consensus scoring (roadmap phase 3) are the likely trigger.
Cloudflare Durable Objects with SQLite storage
Strong consistency, per-object transactions, and a natural fit if the model were partitioned by crag. Rejected as premature: it is a more specialised tool, the access patterns are not established yet, and the mental overhead of "which object owns this row" is real. Worth revisiting if per-crag write contention ever becomes a thing.
Serverless Postgres via Hyperdrive from a Worker
Keeps Workers, gets Postgres. Rejected because it reintroduces the connection-pool and cold-start problems D1 avoids, and doubles the number of systems in the deploy path for a workload that fits SQLite comfortably.
A conventional container on Fly / Render / a VPS
Simplest to reason about, no runtime restrictions. Rejected on cold-start latency and on the cost
of per-PR environments — spinning a container and a database per pull request is a lot more moving
parts than wrangler versions upload.
Open questions
- Rate limiting. There is none. A public sync endpoint without limits is a bad idea before launch, and it needs its own decision.
- Read replication. Worth turning on before there are users outside Europe.
- Image storage. R2 is the obvious answer and is not configured. Deliberately left out of
wrangler.jsoncso day-one deploys do not fail on a missing bucket. Needs its own ADR — see roadmap phase 1. - Backups. D1 has time-travel; we have not established a retention policy or tested a restore.