Template
Initial commit: Bun · Hono · React SPA · Postgres/Drizzle template
The starting point for an app with a React frontend. Same server, env, database and API skeleton as template-htmx — the two differ only in what is served at / — with a React 19 SPA, Tailwind v4 and shadcn/ui on top. Carried from a working project rather than designed in the abstract, and carrying the lessons that cost it time: - The dev script states NODE_ENV=development rather than inheriting it. React resolves its runtime against NODE_ENV: Bun emits jsxDEV() in development and React's production jsx-dev-runtime defines jsxDEV as void 0. Inherit production while transforming for development and the app dies at first paint. Verified: the dev bundle resolves the development runtime and contains no void-0 stub. - Asset URLs in components are relative and <base href="/"> is in the HTML, so a path-prefix proxy can rewrite one attribute and every asset follows. A test enforces it. - Routing is in the hash, because the app may be mounted under a prefix it only learns at runtime and a hash means the same thing at either mount. - index.html holds __PUBLIC_URL__ and gen-index.ts substitutes it, because Bun's HTML bundler fails the build on a root-relative favicon href. - APP_ENV failing closed, env files before the ambient environment, the database enforced by name, one Postgres client per process — as in the htmx template. The database has no tables. The App is a shell with one tab and no features, named as a placeholder. Renaming `app` is the first step and the README lists every place it appears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# Database
|
||||
|
||||
One PostgreSQL database, `app`, exposed as the package `appdb`. Managed with Drizzle. There is no
|
||||
second database and no cache database.
|
||||
|
||||
```
|
||||
src/databases/app_db/
|
||||
├── drizzle.config.ts
|
||||
├── package.json exports ".", "./db", "./schema", "./*"
|
||||
└── src/
|
||||
├── db.ts the connection
|
||||
├── env.ts which database this process may reach
|
||||
├── index.ts public surface: one `export * from './<feature>'` per line
|
||||
├── schema.ts what db:push creates — see below
|
||||
└── <feature>/
|
||||
├── index.ts what this feature exports, declared beside the code it describes
|
||||
├── schema.ts its tables
|
||||
└── queries.ts everything that reads or writes them
|
||||
```
|
||||
|
||||
## A feature owns its own public surface
|
||||
|
||||
`src/index.ts` is one `export *` per feature and nothing else. What a feature exports is declared in
|
||||
its own `index.ts`.
|
||||
|
||||
Adding a query is then one file in one directory, rather than that file plus a hand-written list of
|
||||
every symbol in the package. Those lists drift: the platform's reached 297 lines with twelve features
|
||||
listed twice before it was replaced.
|
||||
|
||||
## One directory per feature, holding both halves
|
||||
|
||||
Not parallel `schema/` and `queries/` trees. Those drift too — the platform ended up with four
|
||||
features named differently on each side, one with no query file and one with no schema file, and
|
||||
nothing structural said so.
|
||||
|
||||
A directory that is lopsided says so by its contents.
|
||||
|
||||
## `src/schema.ts` is drizzle-kit's view, not the runtime's
|
||||
|
||||
`drizzle.config.ts` points at it, so **commenting one line there removes a table from the DATABASE
|
||||
without removing a line of code**. Every query imports its tables from `./schema` inside its own
|
||||
feature directory, so the code still compiles.
|
||||
|
||||
That is what lets a fresh install create only the tables it needs, with optional ones commented out
|
||||
until something wants them.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
```bash
|
||||
bun run db:push # -> app_dev
|
||||
APP_ENV=production bun run db:push # -> app, deliberately
|
||||
```
|
||||
|
||||
`db:push` diffs the schema code against the live database and alters it directly. **Treat the schema
|
||||
code as the source of truth.** `db:gen` writes files to `migrations/`, but nothing applies them — do
|
||||
not start reading those files as though they were the record.
|
||||
|
||||
## The schema is the source of truth for what the database may CONTAIN
|
||||
|
||||
Not just for its shape. Where a column has a known set of legal values, say so with a `check()` rather
|
||||
than leaving it free `text` — otherwise a value the code stopped supporting sits there unnoticed, and
|
||||
nothing structural says it became illegal.
|
||||
|
||||
```ts
|
||||
export const ORDER_STATUSES = ["pending", "paid", "shipped"] as const;
|
||||
|
||||
status: text("status").notNull().$type<(typeof ORDER_STATUSES)[number]>(),
|
||||
// in the table's second argument:
|
||||
check("orders_status_check", sql`${table.status} in ('pending', 'paid', 'shipped')`),
|
||||
```
|
||||
|
||||
**But `text` without a check where the set must stay open.** A category, a provider name, anything a
|
||||
third party can add: an enum cannot contain a value nobody has compiled yet, and adding one must not
|
||||
be a schema change. The test is whether the set is closed by us or open to the world.
|
||||
|
||||
## Which database a process reaches
|
||||
|
||||
Decided by `APP_ENV` and **enforced** in `env.ts` before a connection opens:
|
||||
|
||||
| `APP_ENV` | Database |
|
||||
|---|---|
|
||||
| `production` | `app` (`POSTGRES_URL`, from `.env.production.local`) |
|
||||
| `development` | `app_dev` (`POSTGRES_URL_DEV`, from `.env`) |
|
||||
|
||||
Any other pairing throws. The check is on the database **name**, because host and credentials are
|
||||
usually shared and one word at the end of a long URL is all that separates them.
|
||||
|
||||
## Things that bite, already handled here
|
||||
|
||||
**The client is a per-process singleton on `globalThis`.** `bun --hot` re-evaluates modules on every
|
||||
edit; without it each save builds a fresh pool and abandons the last, queries in flight during the
|
||||
swap never resolve or release their connection, and the pool eventually wedges — every route that
|
||||
reads hangs while stateless ones stay fast.
|
||||
|
||||
**`waitForDatabase()` before startup work.** `postgres()` is lazy, so nothing fails at import when the
|
||||
database is not up. Startup work that queries otherwise fires once, fails, and is swallowed by
|
||||
whatever `.catch()` it was written with.
|
||||
|
||||
**Money is `numeric`, never a float**, and it comes back as a string. Sums of binary floating point
|
||||
are wrong in ways nobody notices until an invoice.
|
||||
|
||||
**Denormalise what is historical fact.** A row pointing at configuration — a provider, a price list —
|
||||
should also record what it actually used, as text. The configuration can be renamed, repointed or
|
||||
deleted; what happened cannot.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests run against `app_dev`; the `APP_ENV` guard makes production unreachable from them.
|
||||
|
||||
Two rules learned expensively: **clean up what you write**, and **restore what you disturb**. A test
|
||||
that sets a "default" flag clears it on every other row, so deleting only your own rows afterwards is
|
||||
not enough — snapshot the previous value and put it back.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Apps and surfaces
|
||||
|
||||
Two surfaces, **one process, one port, one database**:
|
||||
|
||||
```
|
||||
example.com/ ──┤ apps/frontend │ React SPA, talks to /api
|
||||
example.com/api/thing ──┤ servers/api │ serializers -> JSON
|
||||
└───────┬─────────┘
|
||||
│
|
||||
┌───────┴─────────┐
|
||||
│ appdb │ queries
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
Here the SPA is a client of the API like any other, which is the point of a SPA. The rule below is
|
||||
about the server side: the API is the only door to the data, and it must never return HTML.
|
||||
|
||||
## HTML and JSON are siblings, not layers
|
||||
|
||||
## The rule
|
||||
|
||||
**Both surfaces call the query layer. Neither calls the other.**
|
||||
|
||||
### The web app does not call its own API
|
||||
|
||||
It runs in the same process. Going out over HTTP to talk to ourselves buys a round trip, a
|
||||
serialisation, a deserialisation, and a second way for two screens to disagree. It also makes every
|
||||
page render depend on the server being able to reach itself, which is a failure mode nobody wants to
|
||||
debug.
|
||||
|
||||
Import the query function. That is what it is for.
|
||||
|
||||
### The API does not return HTML
|
||||
|
||||
Not even for htmx. It is tempting — the fragment is right there — but the API is a documented,
|
||||
external contract consumed by things that are not this website, and htmx's needs are not theirs. A
|
||||
fragment that becomes a de facto API cannot be changed without breaking someone, and you find out
|
||||
when they tell you.
|
||||
|
||||
Fragments belong to `apps/web` and are free to change on any afternoon, precisely because nothing
|
||||
outside this repo depends on them.
|
||||
|
||||
## What they share, and what they must not
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Shared** | query functions, domain rules, database types |
|
||||
| **Not shared** | serialisation, error presentation, validation of transport, URL shape |
|
||||
|
||||
The API has serializers because JSON needs a stable, named, snake_case shape. The web app has
|
||||
templates because HTML needs markup. Trying to make one produce the other gives you a serializer with
|
||||
markup in it.
|
||||
|
||||
## They are allowed to behave differently
|
||||
|
||||
This is the part that feels wrong and is right.
|
||||
|
||||
The two surfaces have different callers, and the same event can deserve different answers:
|
||||
|
||||
| Situation | `/api` | the site |
|
||||
|---|---|---|
|
||||
| Creating something whose name is taken | **409** with an error envelope | show the list, with the existing one in it |
|
||||
| A malformed id | **404**, in the envelope | render the page's empty state |
|
||||
| Something failed | error object with a `type` a program can branch on | a sentence a person can read |
|
||||
|
||||
A program needs to distinguish "already exists" from "created". A human clicking Create wants the
|
||||
thing they asked for to be on screen; being shown an error for a collection that exists is a worse
|
||||
answer than showing it to them.
|
||||
|
||||
**What must never diverge is the query layer.** If the two surfaces can produce different *data* for
|
||||
the same request, that is a bug. If they present the same data differently, that is the point.
|
||||
|
||||
## Naming
|
||||
|
||||
`/thing` and `/api/thing` stay parallel. `/api` is mounted before the site's catch-all in
|
||||
`src/server.ts`, so it is resolved first and the site never swallows an API path.
|
||||
|
||||
Within the site, pages and partials are **different URLs**, never the same URL switching on
|
||||
`HX-Request` — see `docs/WEB.md`. Combined with the `/api` split, that gives three URL spaces with one
|
||||
representation each, and every one of them is cacheable without a `Vary` header.
|
||||
|
||||
## Adding a resource
|
||||
|
||||
1. `app_db/src/<feature>/` — schema and queries. Both surfaces will import these.
|
||||
2. `servers/api/<feature>.ts` — a router, plus a serializer in the shared serializer module.
|
||||
3. `apps/web/<feature>/partials.tsx` and a page — both calling the same queries.
|
||||
4. Mount the API router in `servers/hono.ts`; add the site's routes in `apps/web/index.tsx`.
|
||||
|
||||
If step 3 finds itself wanting something step 2 already computed, that logic belongs in step 1.
|
||||
Reference in New Issue
Block a user