Modules
Module: a database
The template stores nothing, on purpose — licensing is the payment provider's, end to end, and the only durable licence state is the Keychain item on the customer's Mac. Add a database when you have something worth keeping that has no other home: a waitlist, a "manage my Macs" roster, orders you want to reconcile yourself.
This is the layer a source project ran in production — Postgres through
Drizzle, on Neon's HTTP driver or node-postgres depending on the URL. It is
copied here as code rather than shipped dormant in site/, so a product that
never needs it carries no dependency for it.
Two rules, before any code. They are the parts that are easy to get subtly wrong and hard to notice:
- Nothing imports the database at module scope.
site/lib/db/index.tsresolves its connection string when it loads and throws when there is none, so a staticimportbreaks the build of every page in that route's module graph on a machine with no database — which is every clone. CheckhasDatabaseUrl()first, thenawait import("@/lib/db"). The dynamic import is load-bearing, not stylistic. - Absent is not broken. The build skips migrations when there is no
database URL (a zero-environment deploy must succeed — root
AGENTS.md§4) and fails when there is one and migrating does not work: code deployed against a schema that never applied is a site that is up, looks fine, and 500s on the one route that stores anything.
And one obligation: adding a table is a privacy-policy edit in the same
commit. site/app/privacy/page.tsx is written from the code, and
LEGAL.effective in site/lib/legal.ts moves with it.
1. Dependencies
cd site
pnpm add drizzle-orm pg @neondatabase/serverless
pnpm add -D drizzle-kit @types/pg
and in site/package.json scripts:
"db:migrate": "tsx scripts/migrate.ts"
2. site/lib/db/env.ts — finding the connection string
import { SITE } from "../site";
/**
* The prefix a hosting integration gives the variables it injects. Vercel's
* Postgres integrations name them `<project>_DATABASE_URL` and friends, where
* <project> is the project name lowercased with runs of other characters
* collapsed to underscores — which is exactly this, derived from SITE.name so
* a rename renames it too. Replace it with a literal only when the hosting
* project is named something other than the product.
*/
const ENV_PREFIX = SITE.name.toLowerCase().replace(/[^a-z0-9]+/g, "_");
const CANDIDATES = [
"DATABASE_URL",
`${ENV_PREFIX}_DATABASE_URL`,
`${ENV_PREFIX}_POSTGRES_URL`,
"POSTGRES_URL",
] as const;
function firstConfigured(): string | null {
for (const name of CANDIDATES) {
const value = process.env[name];
if (value && value.trim()) return value.trim();
}
return null;
}
/** Ask this before importing `@/lib/db`. */
export function hasDatabaseUrl(): boolean {
return firstConfigured() !== null;
}
export function resolveDatabaseUrl(): string {
const url = firstConfigured();
if (url) return url;
throw new Error(
`No database URL found. Set DATABASE_URL, or deploy where a Postgres ` +
`integration provides one. Looked for: ${CANDIDATES.join(", ")}.`,
);
}
/**
* Migrations want a direct connection, not a pooler: DDL through a
* transaction-mode pooler fails in ways that look like a broken migration.
*/
export function resolveMigrationUrl(): string {
return (
process.env[`${ENV_PREFIX}_DATABASE_URL_UNPOOLED`]?.trim() ||
process.env.DATABASE_URL_UNPOOLED?.trim() ||
process.env.POSTGRES_URL_NON_POOLING?.trim() ||
resolveDatabaseUrl()
);
}
export function isNeon(url: string): boolean {
return /neon\.tech|neon\.build/.test(url);
}
3. site/lib/db/index.ts and schema.ts
// index.ts — never imported statically; see rule 1.
import { neon } from "@neondatabase/serverless";
import { drizzle as drizzleNeon } from "drizzle-orm/neon-http";
import { drizzle as drizzleNode } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { isNeon, resolveDatabaseUrl } from "./env";
import * as schema from "./schema";
const url = resolveDatabaseUrl();
// Neon's HTTP driver has no interactive transactions. Code written against
// the guarded-UPDATE pattern in lib/payments/webhook.ts works on both drivers,
// which is what keeps this switch invisible to everything above it.
export const db = isNeon(url)
? drizzleNeon(neon(url), { schema })
: drizzleNode(new Pool({ connectionString: url }), { schema });
export { schema };
// schema.ts
import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
export const subscribers = pgTable(
"subscribers",
{
id: uuid("id").primaryKey().defaultRandom(),
// Lowercased and trimmed by the route before insert: the unique index is
// case-sensitive.
email: text("email").notNull(),
source: text("source"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex("subscribers_email_idx").on(t.email)],
);
site/drizzle.config.ts:
import { defineConfig } from "drizzle-kit";
import { resolveMigrationUrl } from "./lib/db/env";
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: resolveMigrationUrl() },
});
Generate the first migration with pnpm exec drizzle-kit generate and commit
the drizzle/ directory it writes.
4. Migrations in the build — absent versus broken
Inside main() in site/scripts/bootstrap.ts, after the site-URL gate:
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { hasDatabaseUrl, resolveMigrationUrl } from "../lib/db/env";
if (!hasDatabaseUrl()) {
console.log("[bootstrap] no database URL; skipping migrations.");
return;
}
const pool = new Pool({ connectionString: resolveMigrationUrl() });
try {
await migrate(drizzle(pool), { migrationsFolder: "./drizzle" });
console.log("[bootstrap] migrations applied");
} catch (error) {
// Broken, not absent: fail the deploy rather than ship code against a
// schema that never applied.
console.error("[bootstrap] migrations failed:", error);
await pool.end().catch(() => {});
process.exit(1);
}
await pool.end();
site/scripts/migrate.ts is the same migrate() call, for running by hand.
5. Keeping the waitlist
In site/app/api/subscribe/route.ts, replace the TODO:
import { hasDatabaseUrl } from "@/lib/db/env";
if (hasDatabaseUrl()) {
try {
const { db, schema } = await import("@/lib/db");
await db.insert(schema.subscribers).values({ email, source }).onConflictDoNothing();
} catch (error) {
// The address is worth more than the row: log it and still say yes.
console.error("[subscribe] insert failed:", error instanceof Error ? error.message : error);
}
}
The response stays identical whether the address was new, a duplicate, or not stored — see the route's own comment on enumeration.
6. Verify
pnpm verifywith no database variables: every page builds, every test passes. That is rule 2 from the outside.- With a database:
pnpm buildapplies migrations; a deliberately brokenDATABASE_URLfails the build. - Update
site/AGENTS.md's zero-configuration table and the privacy page.
This page is docs/modules/database.md in the repository, copied 2026-09-25.