# Database

## Schema Changes Workflow

1. Edit `database/schema.ts`
2. `npm run db:generate` — generates migration SQL + snapshot
3. Verify the SQL in `drizzle/` looks correct
4. Verify a snapshot was created in `drizzle/meta/` (e.g. `0070_snapshot.json`)
5. `npm run db:migrate` — applies migration
6. Add model functions in `app/models/` for new queries

## Drizzle Migration Rules

**NEVER** manually create migration SQL files or edit `drizzle/meta/_journal.json`. The journal and snapshots must stay in sync — drizzle-kit manages both. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed".

- Always use `drizzle-kit generate` and answer any interactive rename prompts (`"Is X renamed from Y?"`) — do NOT use `--custom` for renames, it copies the previous snapshot instead of diffing, which causes the next run to generate a duplicate migration
- Snapshot JSON must only contain PostgreSQL-valid fields — do not add `"autoincrement"` (Postgres uses sequences, and invalid fields cause Zod validation failures)
- After generating, run `drizzle-kit generate` again to confirm "No schema changes" — validates the snapshot chain
- For destructive migrations (drop/rename), use `IF EXISTS` / `IF NOT EXISTS` guards
- Some existing FK constraints use Postgres auto-generated `*_fkey` names instead of the Drizzle `*_fk` convention. If drizzle-kit generates `DROP CONSTRAINT "<table>_<col>_<ref>_id_fk"` and it fails with "does not exist", add `IF EXISTS` (via `sed -i` on the SQL file) — a mismatched name is the likely cause

## Canonical vs. Per-Window Tables (Qualifying-Points Sports)

Golf, tennis, and CS2 use a split model to support rolling windows that overlap (e.g., a June and September tennis window both containing Wimbledon 2026 + US Open 2026).

- **Canonical** (real-world identity, shared across windows):
  - `tournaments` — real-world tournaments (Wimbledon 2026 is one row per sport)
  - `participants` — real-world players or teams (Djokovic, NAVI)
  - `tournament_results` — final placements, entered once
  - `participant_surface_elos` — tennis surface-specific Elo
- **Per-window** (scoped to a `sports_seasons` row):
  - `season_participants` — draftable roster for this window; links to canonical via `participant_id`
  - `season_participant_expected_values` — EV under this window's 4-event set
  - `season_participant_qualifying_totals` — running QP sum for this window
  - `season_participant_results` — final placement for fantasy scoring
  - `event_results` — per-scoring-event placements (derived from `tournament_results` via `syncTournamentResults`)

Enter tournament results at `/admin/tournaments/:id`. The `syncTournamentResults` service (`app/services/sync-tournament-results.ts`) fans out to every window linked via `scoring_events.tournament_id` — each window in its own transaction, so partial failures isolate. A CHECK constraint on `scoring_events` enforces `NOT is_qualifying_event OR tournament_id IS NOT NULL`.

When admin creates a new qualifying event via the events admin page, `createScoringEvent` auto-provisions the matching canonical tournament. When admin creates a new season_participant, `createParticipant` auto-links to a canonical participant by `(sport_id, name)`.

Tennis note: Brackt models tennis as two sports (`Tennis - Men`, `Tennis - Women`). Each real-world tournament becomes two canonical rows. Entering women's Wimbledon does not fan out to men's windows — by design.

## Scoring Event Dates (Non-Obvious Gotcha)

Event dates are stored in **two different columns** depending on event type. Any date-range query must account for both:

1. **`scoringEvents.eventDate`** (`date` column, `YYYY-MM-DD`) — used for non-bracket events (majors, final standings) and sometimes bracket events when the admin sets one date for an entire round.

2. **`playoffMatchGames.scheduledAt`** (`timestamp`) — used for individual games within a bracket match. Bracket events often have `eventDate = null` on the scoring event itself.

**Pattern for date-range queries:** Two-step approach:
1. `selectDistinct` event IDs via left joins through `playoffMatches → playoffMatchGames`, filtering: `eventDate IN [dates] OR (scheduledAt >= dayStart AND scheduledAt <= dayEnd)`
2. `findMany` on the matched IDs to load full event data with relations

All timestamp bounds use UTC (`T00:00:00.000Z` / `T23:59:59.999Z`).

Reference implementation: `getEventsForDates` and `getUpcomingEventsForDraftedParticipants` in `app/models/scoring-event.ts`.
