Build Mac AppDocsMenu

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:

  1. Nothing imports the database at module scope. site/lib/db/index.ts resolves its connection string when it loads and throws when there is none, so a static import breaks the build of every page in that route's module graph on a machine with no database — which is every clone. Check hasDatabaseUrl() first, then await import("@/lib/db"). The dynamic import is load-bearing, not stylistic.
  2. 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 verify with no database variables: every page builds, every test passes. That is rule 2 from the outside.
  • With a database: pnpm build applies migrations; a deliberately broken DATABASE_URL fails 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.