Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ If any of the above commands fail or show errors:
- **No Native Dialogs**: Never use native `alert()` or `confirm()` dialogs. Always use shadcn AlertDialog, Dialog, or Sonner toast components instead

## Database Migrations
- **Generate Migrations**: Use `pnpm db:generate` to automatically generate database migrations from schema changes
- **Never Write Manual Migrations**: Do not manually create SQL migration files in the `drizzle/` directory
- **Workflow**:
1. Update the schema in `lib/db/schema.ts`
2. Run `pnpm db:generate` to generate the migration
3. Run `pnpm db:push` to apply the migration to the database
- The migration generator will create properly formatted SQL files based on your schema changes
- **File-based migrations**: production and staging deploys run `pnpm db:migrate` (file-based, `drizzle-kit migrate`), not `db:push`. Migration state is tracked in `drizzle.__drizzle_migrations`. See the "Database Migrations" section in `CLAUDE.md` for the full workflow, journal-timestamp gotcha, and dev-DB bootstrap notes.
- **Standard workflow**:
1. Update the schema in `lib/db/schema.ts` (or `lib/db/schema-*.ts`)
2. Run `pnpm drizzle-kit generate` (or `pnpm db:generate`) to produce the migration file. Use `pnpm drizzle-kit generate --custom` to produce an empty file for a hand-written migration when the auto-generator cannot express the change.
3. Ensure `drizzle/meta/_journal.json` `when` timestamps are monotonically increasing.
4. Commit migration file, snapshot (if generated), journal, and schema change together.
- **Local dev**: `pnpm db:push` is acceptable for fast iteration on a local dev DB only. Never push schema changes via `db:push` against shared envs.
- **Heavy DDL (`-- @requires-db-prep` directive)**: any migration whose intended production form uses `CREATE INDEX CONCURRENTLY`, `REINDEX CONCURRENTLY`, or another non-transactional statement must put `-- @requires-db-prep` on the first non-empty line of the SQL file. Write the SQL in its transaction-safe form (plain `CREATE INDEX IF NOT EXISTS` etc.); the directive triggers the `db-prep-check` merge gate which requires an operator to apply the lock-free form against the real target DB manually and flip the matching `db-prepped-<base-branch>` label (`db-prepped-staging`, `db-prepped-prod`). See `CLAUDE.md` "Heavy DDL Migrations" for the operator runbook and the rationale.

## Code Cleanliness
- **Remove Unused Code**: If a variable, import, or function is unused, remove it entirely. Do not prefix with underscore unless it's intentionally unused but required (e.g., function parameters)
Expand Down
40 changes: 40 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,46 @@ If your local dev DB was bootstrapped via `pnpm db:push` (instead of file migrat

Note: a shell-set `DATABASE_URL` overrides the value in `.env` (drizzle.config.ts uses dotenv without `override: true`). If `pnpm db:migrate` connects to the wrong DB or port, run `unset DATABASE_URL` first or prefix the command with the right value.

### Heavy DDL Migrations: the `@requires-db-prep` directive

Some DDL statements cannot run inside a transaction: `CREATE INDEX CONCURRENTLY`, `REINDEX CONCURRENTLY`, `CREATE DATABASE`, `VACUUM`, certain `ALTER TYPE` forms, etc. `drizzle-kit migrate` wraps every migration in a transaction with no per-file opt-out, so these statements cannot live in a migration file directly.

For changes where the lock-free form matters in production (a plain `CREATE INDEX` on a multi-GB table takes a multi-minute `ACCESS EXCLUSIVE` lock during deploy), we use a directive + branch-protection gate instead of running unsafe DDL through `drizzle-kit migrate`.

**When to use the directive:** any migration whose intent on production is to be applied as `CREATE INDEX CONCURRENTLY` or another statement that cannot be wrapped in a transaction. If the migration only touches small tables and a brief lock is acceptable, you do not need the directive.

**How to author the migration:**

1. Put `-- @requires-db-prep` on the first non-empty line of the SQL file.
2. Write the SQL using the **transaction-safe** form with `IF NOT EXISTS` / `IF EXISTS`. Example:
```sql
-- @requires-db-prep
-- KEEP-XXX: index on hot column for query Y
CREATE INDEX IF NOT EXISTS idx_foo_bar ON foo (bar);
```
The `IF NOT EXISTS` clause makes the migration a no-op on prod after step 3 below, and lets it run safely on dev / PR-environment DBs where the table is small.
3. Update `lib/db/schema.ts` (or the matching schema file) to declare the index via the `index()` / `uniqueIndex()` helper, so drizzle-kit does not see it as drift.

**Before merge - operator runbook:**

For each environment the PR will deploy to (staging on PRs to `staging`, prod on staging->prod release PRs):

1. Connect to the target DB.
2. For each statement in the migration, run the **lock-free** form against the real DB. For indexes that is `CREATE INDEX CONCURRENTLY IF NOT EXISTS ...` instead of plain `CREATE INDEX IF NOT EXISTS ...`. Each statement must run individually (not inside a transaction block).
3. Verify there are no INVALID indexes left behind:
```sql
SELECT i.relname FROM pg_index ix JOIN pg_class i ON i.oid = ix.indexrelid WHERE NOT ix.indisvalid;
-- expect: (0 rows)
```
4. If an INVALID index is present (CONCURRENTLY can leave one if a statement errors mid-build), drop it with `DROP INDEX CONCURRENTLY IF EXISTS <name>` and re-run the failing CREATE.
5. Apply the matching label to the PR: `db-prepped-staging` for PRs targeting `staging`, `db-prepped-prod` for PRs targeting `prod`.

**The merge gate:** the `.github/workflows/db-prep-check.yml` workflow scans every PR's diff for newly-added `drizzle/*.sql` files containing the directive. If any are found, the `db-prep-check` status check fails until the matching `db-prepped-<base-branch>` label is set. `db-prep-check` is a required status check in the repo's branch-protection ruleset for `staging`, `prod`, and `main`, so a missing label blocks merge at the GitHub branch-protection layer. PRs without the directive in any added migration pass the check silently.

**On deploy:** because the indexes were already created out-of-band, drizzle-kit's plain `CREATE INDEX IF NOT EXISTS` short-circuits before acquiring any lock and the migration is a true no-op. drizzle-kit records the migration hash in `drizzle.__drizzle_migrations` so future deploys skip it.

Born from the 2026-05-05 RDS CPU incident (KEEP-432).

## Branch Strategy

- **Main branch**: `staging`
Expand Down
Loading