---
description: Neon Postgres conventions + the until-we-have-migrations workflow
globs: pages/api/**/*.js,lib/database.js,scripts/**/*.js
---

# DB + schema

## Clients

Two are installed (`@vercel/postgres` + `@neondatabase/serverless`); they point at the same Neon Postgres. New code uses `@vercel/postgres` (tagged-template SQL).

```js
import { sql } from '@vercel/postgres';

const { rows } = await sql`
  SELECT id, name, set_name
  FROM cards
  WHERE game = ${game}
    AND set_code = ${setCode}
  LIMIT 50
`;
```

**Never** use `lib/database.js`'s `db.query(string, params)` API for new code — it interpolates params into a string and then calls `sql.unsafe()`, which is a SQL-injection vector. Marked for removal in `.convoys/`.

## Schema source of truth

`migrations/` at the repo root owns the schema (post-`migration-tool` convoy, 2026-05-26). The initial backfill `migrations/1779853647564_initial-schema.js` reproduces the 7-table bootstrap shape verbatim from `scripts/setup-neon-db.js`. The tool is `node-pg-migrate@^8`; tracking table is the default `pgmigrations`. To add a column:

- **Adding a column**: `npm run migrate create add-<table>-<column> -- -j js`, edit the generated file in `migrations/`, then `npm run migrate up` to apply.
- **Document** the change in `docs/SCHEMA_MAP.md`.
- **Never** edit a migration that has already been applied (the `pgmigrations` row pins the file's contents — mutating it silently corrupts every env that has the prior version recorded).
- **Never** edit historical `scripts/add-*.js` / `scripts/fix-*.js` / `scripts/seed-*.js` jobs — they're append-only history per the no-go-zones rule.

`scripts/setup-neon-db.js` now spawns `npm run migrate up` before seeding the admin user; do not put DDL back into it.

## Tables (current)

| Table | Owner | Notes |
| --- | --- | --- |
| `users` | core | `(id, email UNIQUE, password, role, created_at, …)` |
| `cards` | core | Big — `oracle_text TEXT`, `colors JSONB`. Don't `SELECT *`. |
| `user_cards` | per-user | `(user_id, card_id, quantity, condition, is_foil)` — UNIQUE on tuple |
| `collections` | per-user | `is_public BOOLEAN`, `slug`, `tags`, `image_url`, `system_collection` |
| `collection_cards` | join | `(collection_id, card_id, quantity)` UNIQUE |
| `collection_permissions` | per-user | `(collection_id, user_id, role, status)` — `viewer\|editor\|owner` |
| `collection_activity` | log | `(collection_id, user_id, action, details JSONB, created_at)` |
| `decks` / `deck_cards` | per-user | Mirror of collections |
| `favorites` | per-user | `(user_id, card_id)` |
| `invitations` | per-collection | Pending share requests |

Full map: [`docs/SCHEMA_MAP.md`](../../docs/SCHEMA_MAP.md). Regenerate by re-reading `setup-neon-db.js` + every `add-*.js` script that's been run.

## Indexing reminders

- `cards.scryfall_id` is UNIQUE — use it for dedupe on import.
- `users.email` is UNIQUE — case-insensitive collation NOT set; lowercase before query/insert.
- `collections.slug` should be UNIQUE per user; verify with `lib/slug-utils.js::generateUniqueSlug` before insert.

## Transactions

Neon HTTP doesn't support multi-statement transactions across separate `sql` calls — each call is its own connection. For multi-table writes that need atomicity, use Neon's `sql.transaction([query1, query2])` array form OR refactor to a single SQL statement with CTEs. The current codebase has several non-atomic multi-step inserts that should be flagged.
