Files
template-react/docs/DATABASE.md
T
pastilhasandClaude Opus 5 c08dab4243 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>
2026-08-18 02:02:52 +00:00

5.0 KiB

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

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.

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.