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>
4.5 KiB
Database
Schema Changes Workflow
- Edit
database/schema.ts npm run db:generate— generates migration SQL + snapshot- Verify the SQL in
drizzle/looks correct - Verify a snapshot was created in
drizzle/meta/(e.g.0070_snapshot.json) npm run db:migrate— applies migration- 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 generateand answer any interactive rename prompts ("Is X renamed from Y?") — do NOT use--customfor 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 generateagain to confirm "No schema changes" — validates the snapshot chain - For destructive migrations (drop/rename), use
IF EXISTS/IF NOT EXISTSguards - Some existing FK constraints use Postgres auto-generated
*_fkeynames instead of the Drizzle*_fkconvention. If drizzle-kit generatesDROP CONSTRAINT "<table>_<col>_<ref>_id_fk"and it fails with "does not exist", addIF EXISTS(viased -ion 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 onceparticipant_surface_elos— tennis surface-specific Elo
- Per-window (scoped to a
sports_seasonsrow):season_participants— draftable roster for this window; links to canonical viaparticipant_idseason_participant_expected_values— EV under this window's 4-event setseason_participant_qualifying_totals— running QP sum for this windowseason_participant_results— final placement for fantasy scoringevent_results— per-scoring-event placements (derived fromtournament_resultsviasyncTournamentResults)
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:
-
scoringEvents.eventDate(datecolumn,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. -
playoffMatchGames.scheduledAt(timestamp) — used for individual games within a bracket match. Bracket events often haveeventDate = nullon the scoring event itself.
Pattern for date-range queries: Two-step approach:
selectDistinctevent IDs via left joins throughplayoffMatches → playoffMatchGames, filtering:eventDate IN [dates] OR (scheduledAt >= dayStart AND scheduledAt <= dayEnd)findManyon 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.