Architecture notes · build-in-public
Choosing D1 over Postgres for an all-Cloudflare build
This project stores its content and learning state in Cloudflare D1 (SQLite), not Postgres. The reason in the design doc is plain: "all-Cloudflare for the slice; swap to Supabase Postgres later if needed." It's a deliberate, recorded, and reversible call — not a default that happened by accident.
The decision is logged as locked for now, open at scale: D1 for the vertical slice, with "D1 vs Supabase Postgres at scale" explicitly listed as an open question to revisit if and when the product goes multi-user.
The decision, as written down
The architecture table in the design doc names the relational store and its reason in one line: relational data is D1 (SQLite), "all-Cloudflare for the slice; swap to Supabase Postgres later if needed." The same doc's decisions section lists "D1 for the slice" under Locked, and lists "D1 vs Supabase Postgres at scale (revisit if/when multi-user)" under Open. So the choice and its expiry condition were both recorded up front.
This matters because the project's governing principle is to resist over-engineering — the senior trap the course's own Track 0 teaches. Standing up a managed Postgres, a connection pooler, and the networking to reach it from an edge Worker is real work. For a build whose first job is to ship a thin vertical slice end-to-end, the question isn't "which database is best in general" but "what lets the loop ship while keeping the swap cheap." D1 answered that.
Why D1 fit the slice
- It's native to the stack. The whole architecture is Cloudflare-native — Workers, R2, Durable Objects, AI Gateway. D1 is the relational piece of that same platform, bound to the Worker as
DBinwrangler.jsoncwith no external network hop, no pooler, and no second vendor to provision. - It runs locally with no cloud. The first two slices were designed to need no cloud account at all —
wrangler devuses a local D1 by default, ignoring the pinned production database id. That kept the inner loop fast and free. - The data model is modest. D1 holds content metadata and learning state — users, lessons, objectives, items, attempts, reviews, mastery. The lesson bodies were always intended to live in R2 as MDX, not in the relational store. SQLite is a comfortable fit for that shape.
- It stayed on the free tier. D1 (and the SQLite-backed coach Durable Object) run on Cloudflare's free plan; only Vectorize / AI Search forced the paid plan later. The database choice didn't gate shipping.
The boundary that makes it reversible
A decision marked "swap later if needed" is only honest if the swap is actually cheap. The project's standing rule is to isolate churny or swappable services behind a thin src/lib/ layer so a change is a one-file fix. Database access follows the same instinct: routes don't hand-write SQL against D1 everywhere — they go through helpers in src/lib/db.ts, and grading specifically runs through one shared function in src/lib/grade.ts that does the full attempt-plus-review-plus-mastery write. Concentrating the persistence calls is what keeps "move to Postgres" a contained edit rather than a scavenger hunt.
The honest caveats
This is not a claim that D1 beats Postgres. It's a claim that, for an education-first vertical slice on an all-Cloudflare stack, D1 was the lower-friction choice and the design doc says so. The open question is real: the doc itself flags the at-scale comparison as unresolved, and names Supabase Postgres as the concrete alternative if multi-user load arrives. The skill on display isn't picking SQLite — it's recording the decision, its reason, and the condition that reverses it, then building the boundary that keeps the reversal cheap.
Decisions like this are the curriculum.
Knowing when D1 is enough and when to reach for managed Postgres — and how to keep the swap a one-file change — is exactly the senior-architect judgement the course is built to teach, across Anthropic, AWS, and Cloudflare.
docs/DESIGN.md §1— architecture table: Relational = "D1 (SQLite) … all-Cloudflare for the slice; swap to Supabase Postgres later if needed."docs/DESIGN.md §2— the D1 data model (users, lessons, objectives, items, …); "Content body lives in R2 as MDX; D1 holds metadata + learning state."docs/DESIGN.md §8— Locked: "D1 for the slice"; Open: "D1 vs Supabase Postgres at scale (revisit if/when multi-user)."docs/PLAN.md §6— Cloudflare-native technical architecture (the all-Cloudflare premise).wrangler.jsonc— theDBD1 binding (databaseaiarch); note that SQLite-backed Durable Objects run on the free plan and Vectorize/AI Search require the paid plan.src/lib/db.ts/src/lib/grade.ts— persistence concentrated behind thelib/boundary (the one-file-fix isolation rule).- Commit
a53359e"deploy(prod): pin remote D1 id + aiarch.dev custom domain";CLAUDE.mdlocal-dev note thatwrangler devignores the pinned prod database id locally.
Build-in-public note, grounded entirely in this repository. Spot a mistake? hello@aiarch.dev.