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:
2026-08-18 02:02:52 +00:00
co-authored by Claude Opus 5
commit c08dab4243
47 changed files with 2424 additions and 0 deletions
+112
View File
@@ -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.