brackt/docs/agents/database.md
Chris Parsons d6c555b06e
docs(agents): describe canonical/per-window split
Update database.md with the canonical/per-window model explanation,
the syncTournamentResults fan-out contract, the check constraint,
and the tennis-Men/Women quirk.

Update domain-models.md to reflect the renamed per-window entities
and the new canonical layer.

Also update the drizzle-kit gotchas section with lessons from the
migration: do not use --custom for renames; FK constraint naming
can mismatch Drizzle convention.

Phase 4 Task 6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 23:14:15 +00:00

4.5 KiB

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.