Add design + 5 phase plans: canonical tournament & participant layer
Spec and phased implementation plans for eliminating data duplication across overlapping qualifying-points windows (golf, tennis, CS2) by introducing canonical tournaments, participants, tournament_results, and surface-Elo tables above the existing per-window layer. - Phase 1a: rename existing per-window tables to season_* prefix - Phase 1b: create canonical tables + nullable FKs - Phase 2: backfill canonical from existing in-flight windows - Phase 3: cutover - sync service, admin UI, simulator read switch - Phase 4: cleanup - drop deprecated tables and routes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3611f5620e
commit
81af8907cc
6 changed files with 4524 additions and 0 deletions
|
|
@ -0,0 +1,638 @@
|
||||||
|
# Phase 1a — Rename Per-Window Tables Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Rename the existing per-window tables (`participants`, `participant_expected_values`, `participant_qualifying_totals`, `participant_results`, `participant_surface_elos`) to `season_`-prefixed names, and rename `event_results.participant_id` to `season_participant_id`. Rename `app/models/participant.ts` to `app/models/season-participant.ts`. Ship a fully green build with zero behavior changes.
|
||||||
|
|
||||||
|
**Architecture:** Pure mechanical rename across ~100 files. No new tables, no new columns, no logic changes. This phase must land cleanly before Phase 1b can create the new canonical `participants` table (which reuses the freed name).
|
||||||
|
|
||||||
|
**Tech Stack:** Drizzle ORM, PostgreSQL, React Router 7, TypeScript, Vitest.
|
||||||
|
|
||||||
|
**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 1a section.
|
||||||
|
|
||||||
|
**Rename mapping (authoritative):**
|
||||||
|
|
||||||
|
| Old (table / column / symbol) | New |
|
||||||
|
|---|---|
|
||||||
|
| table `participants` | `season_participants` |
|
||||||
|
| table `participant_expected_values` | `season_participant_expected_values` |
|
||||||
|
| table `participant_qualifying_totals` | `season_participant_qualifying_totals` |
|
||||||
|
| table `participant_results` | `season_participant_results` |
|
||||||
|
| table `participant_surface_elos` | `season_participant_surface_elos` |
|
||||||
|
| column `event_results.participant_id` | `event_results.season_participant_id` |
|
||||||
|
| Drizzle export `participants` | `seasonParticipants` |
|
||||||
|
| Drizzle export `participantExpectedValues` | `seasonParticipantExpectedValues` |
|
||||||
|
| Drizzle export `participantQualifyingTotals` | `seasonParticipantQualifyingTotals` |
|
||||||
|
| Drizzle export `participantResults` | `seasonParticipantResults` |
|
||||||
|
| Drizzle export `participantSurfaceElos` | `seasonParticipantSurfaceElos` |
|
||||||
|
| Drizzle export `participantsRelations` | `seasonParticipantsRelations` |
|
||||||
|
| Drizzle export `participantExpectedValuesRelations` | `seasonParticipantExpectedValuesRelations` |
|
||||||
|
| Drizzle export `participantQualifyingTotalsRelations` | `seasonParticipantQualifyingTotalsRelations` |
|
||||||
|
| Drizzle export `participantResultsRelations` | `seasonParticipantResultsRelations` |
|
||||||
|
| Drizzle export `participantSurfaceElosRelations` | `seasonParticipantSurfaceElosRelations` |
|
||||||
|
| File `app/models/participant.ts` | `app/models/season-participant.ts` |
|
||||||
|
| Relation field `eventResults.participant` | `eventResults.seasonParticipant` |
|
||||||
|
|
||||||
|
**Files affected (counts from pre-flight audit):**
|
||||||
|
- `database/schema.ts` (1 — critical)
|
||||||
|
- `app/models/index.ts` (1)
|
||||||
|
- `app/models/` — 11 model files + 2 test files
|
||||||
|
- `app/routes/` — ~20 route files
|
||||||
|
- `app/services/` — probability-updater + 19 simulator files
|
||||||
|
- `app/utils/` — `sports-data-sync.server.ts`
|
||||||
|
- `server/` — `socket.ts`, test files
|
||||||
|
- `cypress/` — verify none reference these symbols by name
|
||||||
|
|
||||||
|
**Note on granularity:** Because this is a mechanical rename, tasks are batched by scope rather than broken into micro-TDD steps. Each task ends with typecheck + test run as the verification step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: Create baseline snapshot (one-time, before any code changes)
|
||||||
|
|
||||||
|
**Why:** Phase 2 verification depends on proving QP totals didn't drift. Capture now.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/capture-baseline.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the capture script**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/capture-baseline.ts
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { writeFileSync, mkdirSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const OUT_DIR = join(process.cwd(), "test-fixtures", "baselines");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const qpTotals = await db.execute(sql`
|
||||||
|
SELECT pqt.*, ss.id as sports_season_id, s.name as sport_name
|
||||||
|
FROM participant_qualifying_totals pqt
|
||||||
|
JOIN sports_seasons ss ON ss.id = pqt.sports_season_id
|
||||||
|
JOIN sports s ON s.id = ss.sport_id
|
||||||
|
WHERE ss.scoring_pattern = 'qualifying_points'
|
||||||
|
ORDER BY ss.id, pqt.participant_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const eventResults = await db.execute(sql`
|
||||||
|
SELECT er.*, se.sports_season_id
|
||||||
|
FROM event_results er
|
||||||
|
JOIN scoring_events se ON se.id = er.scoring_event_id
|
||||||
|
JOIN sports_seasons ss ON ss.id = se.sports_season_id
|
||||||
|
WHERE ss.scoring_pattern = 'qualifying_points'
|
||||||
|
AND er.qualifying_points_awarded IS NOT NULL
|
||||||
|
ORDER BY se.sports_season_id, er.scoring_event_id, er.participant_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const surfaceElos = await db.execute(sql`
|
||||||
|
SELECT pse.*, ss.sport_id
|
||||||
|
FROM participant_surface_elos pse
|
||||||
|
JOIN sports_seasons ss ON ss.id = pse.sports_season_id
|
||||||
|
ORDER BY ss.id, pse.participant_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
writeFileSync(join(OUT_DIR, "qp-totals-pre-phase1.json"), JSON.stringify(qpTotals.rows, null, 2));
|
||||||
|
writeFileSync(join(OUT_DIR, "event-results-pre-phase1.json"), JSON.stringify(eventResults.rows, null, 2));
|
||||||
|
writeFileSync(join(OUT_DIR, "surface-elos-pre-phase1.json"), JSON.stringify(surfaceElos.rows, null, 2));
|
||||||
|
|
||||||
|
console.log(`Wrote baselines: ${qpTotals.rows.length} QP totals, ${eventResults.rows.length} event results, ${surfaceElos.rows.length} surface elo rows.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run against production data read replica (or local dev DB loaded from `npm run db:sync-prod`)**
|
||||||
|
|
||||||
|
Run: `npx tsx scripts/capture-baseline.ts`
|
||||||
|
Expected: three JSON files created under `test-fixtures/baselines/`; non-zero row counts for QP totals and surface elos; Masters 2026 results visible in `event-results-pre-phase1.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Also capture Monte Carlo simulator output baselines**
|
||||||
|
|
||||||
|
Extend `scripts/capture-baseline.ts` to, for each in-flight qualifying-points `sports_seasons` row, run the matching simulator with a **fixed random seed** and save its output. This JSON is what Phase 3 Task 6's tennis-simulator regression test reads.
|
||||||
|
|
||||||
|
Pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pseudocode appended to scripts/capture-baseline.ts main():
|
||||||
|
import { getSimulator } from "~/services/simulations/registry";
|
||||||
|
// ... for each ss in qualifying-points seasons:
|
||||||
|
const sim = getSimulator(ss.simulatorType!);
|
||||||
|
const output = await sim.run(ss.id, { seed: 42, iterations: 10000 });
|
||||||
|
writeFileSync(join(OUT_DIR, `sim-output-${ss.simulatorType}-${ss.id}.json`), JSON.stringify(output, null, 2));
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the existing `Simulator` interface in `app/services/simulations/types.ts` for the actual method signature; adapt accordingly. If a simulator does not currently accept a `seed`, add one as a non-breaking optional param in this task — it's needed for reproducible baselines.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the baseline fixtures**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/capture-baseline.ts test-fixtures/baselines/
|
||||||
|
git commit -m "chore: capture pre-rename baselines for qualifying-points data and simulator output"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Rename schema exports and table names in `database/schema.ts`
|
||||||
|
|
||||||
|
**Why first:** Schema is the source of truth; the whole codebase compiles against its exports. Easiest to do in one sweep.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `database/schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rename table definitions and string names**
|
||||||
|
|
||||||
|
Apply these replacements in `database/schema.ts`. The safest order: string names first (they're unambiguous), then export names (which must be done with care to avoid breaking references in relations).
|
||||||
|
|
||||||
|
Table rename block 1 — `participants` (~ lines 376-390):
|
||||||
|
```typescript
|
||||||
|
// OLD:
|
||||||
|
export const participants = pgTable("participants", {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
// NEW:
|
||||||
|
export const seasonParticipants = pgTable("season_participants", {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Table rename block 2 — `participant_expected_values` (~ lines 642-672):
|
||||||
|
```typescript
|
||||||
|
// OLD:
|
||||||
|
export const participantExpectedValues = pgTable("participant_expected_values", { ... });
|
||||||
|
// NEW:
|
||||||
|
export const seasonParticipantExpectedValues = pgTable("season_participant_expected_values", { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
Table rename block 3 — `participant_qualifying_totals` (~ lines 537-550):
|
||||||
|
```typescript
|
||||||
|
// OLD:
|
||||||
|
export const participantQualifyingTotals = pgTable("participant_qualifying_totals", { ... });
|
||||||
|
// NEW:
|
||||||
|
export const seasonParticipantQualifyingTotals = pgTable("season_participant_qualifying_totals", { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
Table rename block 4 — `participant_results` (~ lines 414-428):
|
||||||
|
```typescript
|
||||||
|
// OLD:
|
||||||
|
export const participantResults = pgTable("participant_results", { ... });
|
||||||
|
// NEW:
|
||||||
|
export const seasonParticipantResults = pgTable("season_participant_results", { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
Table rename block 5 — `participant_surface_elos` (~ lines 1219-1235):
|
||||||
|
```typescript
|
||||||
|
// OLD:
|
||||||
|
export const participantSurfaceElos = pgTable("participant_surface_elos", { ... });
|
||||||
|
// NEW:
|
||||||
|
export const seasonParticipantSurfaceElos = pgTable("season_participant_surface_elos", { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
Column rename in `eventResults` table (~ lines 458-476):
|
||||||
|
```typescript
|
||||||
|
// OLD:
|
||||||
|
participantId: uuid("participant_id").notNull().references(() => participants.id, { onDelete: "cascade" }),
|
||||||
|
// NEW:
|
||||||
|
seasonParticipantId: uuid("season_participant_id").notNull().references(() => seasonParticipants.id, { onDelete: "cascade" }),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rename relations exports and their field references**
|
||||||
|
|
||||||
|
In `database/schema.ts`, update each `*Relations` export and every `one()/many()` call that referenced the renamed tables. Replacements needed:
|
||||||
|
- `participantsRelations` → `seasonParticipantsRelations`
|
||||||
|
- `participantExpectedValuesRelations` → `seasonParticipantExpectedValuesRelations`
|
||||||
|
- `participantQualifyingTotalsRelations` → `seasonParticipantQualifyingTotalsRelations`
|
||||||
|
- `participantResultsRelations` → `seasonParticipantResultsRelations`
|
||||||
|
- `participantSurfaceElosRelations` → `seasonParticipantSurfaceElosRelations`
|
||||||
|
|
||||||
|
Within `eventResultsRelations` (~ lines 952-961):
|
||||||
|
```typescript
|
||||||
|
// OLD:
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [eventResults.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
// NEW:
|
||||||
|
seasonParticipant: one(seasonParticipants, {
|
||||||
|
fields: [eventResults.seasonParticipantId],
|
||||||
|
references: [seasonParticipants.id],
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
Update every other relation `one()`/`many()` that referenced `participants`, `participantResults`, `participantQualifyingTotals`, `participantSurfaceElos`, or `participantExpectedValues` to use the new table export names.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify schema parses**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: Will fail with many errors in model/route files that still import old names. That's fine for now — schema.ts itself compiling is the win. Confirm the errors are only in files outside `database/schema.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit schema rename alone**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add database/schema.ts
|
||||||
|
git commit -m "refactor(schema): rename per-window tables to season_* prefix
|
||||||
|
|
||||||
|
Renames participants, participant_expected_values, participant_qualifying_totals,
|
||||||
|
participant_results, participant_surface_elos to season_* prefixed names.
|
||||||
|
Renames event_results.participant_id to season_participant_id.
|
||||||
|
Phase 1a of canonical tournament layer migration."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Generate and hand-edit the Drizzle rename migration
|
||||||
|
|
||||||
|
**Why:** `drizzle-kit generate` treats renames as drop+create by default; this loses data. For safe renames, we generate with `--custom` and hand-edit, OR use `drizzle-kit generate` and answer its interactive rename prompts. Using custom is safer here because multiple renames in one migration can confuse the prompt sequence.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `drizzle/0087_rename_per_window_tables.sql` (filename assigned by drizzle-kit; adjust to whatever it generates)
|
||||||
|
- Create: `drizzle/meta/0087_snapshot.json` (generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate a custom migration scaffold**
|
||||||
|
|
||||||
|
Run: `npm run db:generate -- --custom --name=rename_per_window_tables`
|
||||||
|
Expected: Creates a new empty `.sql` file in `drizzle/` and a paired snapshot in `drizzle/meta/`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the rename SQL manually**
|
||||||
|
|
||||||
|
Open the new `drizzle/XXXX_rename_per_window_tables.sql` and replace its contents with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Rename per-window tables to season_* prefix
|
||||||
|
-- Part of canonical tournament layer migration (Phase 1a)
|
||||||
|
|
||||||
|
-- Drop the FK on event_results.participant_id before renaming target
|
||||||
|
ALTER TABLE "event_results" DROP CONSTRAINT IF EXISTS "event_results_participant_id_participants_id_fk";
|
||||||
|
|
||||||
|
-- Rename tables
|
||||||
|
ALTER TABLE "participants" RENAME TO "season_participants";
|
||||||
|
ALTER TABLE "participant_expected_values" RENAME TO "season_participant_expected_values";
|
||||||
|
ALTER TABLE "participant_qualifying_totals" RENAME TO "season_participant_qualifying_totals";
|
||||||
|
ALTER TABLE "participant_results" RENAME TO "season_participant_results";
|
||||||
|
ALTER TABLE "participant_surface_elos" RENAME TO "season_participant_surface_elos";
|
||||||
|
|
||||||
|
-- Rename column on event_results
|
||||||
|
ALTER TABLE "event_results" RENAME COLUMN "participant_id" TO "season_participant_id";
|
||||||
|
|
||||||
|
-- Re-add the FK against the renamed table
|
||||||
|
ALTER TABLE "event_results"
|
||||||
|
ADD CONSTRAINT "event_results_season_participant_id_season_participants_id_fk"
|
||||||
|
FOREIGN KEY ("season_participant_id") REFERENCES "season_participants"("id")
|
||||||
|
ON DELETE cascade ON UPDATE no action;
|
||||||
|
|
||||||
|
-- Rename any unique indexes that embed the old table names
|
||||||
|
ALTER INDEX IF EXISTS "participant_surface_elos_unique" RENAME TO "season_participant_surface_elos_unique";
|
||||||
|
-- (Add further ALTER INDEX ... RENAME TO for any other indexes whose names embed old table names.
|
||||||
|
-- Run `\d <new_table_name>` in psql against a fresh migrate to confirm nothing lingers.)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** FK and index constraint names are auto-generated based on table names. After the `ALTER TABLE ... RENAME TO`, Postgres does NOT auto-rename the FK constraint on `event_results` pointing at the old name — we drop and re-create explicitly above. Run the migration against a scratch DB and inspect `\d event_results` and `\d season_participants` to catch any leftover naming.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify snapshot is consistent**
|
||||||
|
|
||||||
|
Run: `npm run db:generate`
|
||||||
|
Expected: "No schema changes" — confirms the snapshot chain is valid. If drizzle-kit wants to generate another migration, stop and diagnose; the snapshot is out of sync with the custom SQL.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run migration on local DB**
|
||||||
|
|
||||||
|
Run: `npm run db:migrate`
|
||||||
|
Expected: New migration applied; existing data intact (verify row counts match pre-rename via `psql ... -c "SELECT count(*) FROM season_participants;"` = prior `SELECT count(*) FROM participants;`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add drizzle/
|
||||||
|
git commit -m "migration: rename per-window tables to season_* prefix"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Rename and update `app/models/participant.ts` → `app/models/season-participant.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rename: `app/models/participant.ts` → `app/models/season-participant.ts`
|
||||||
|
- Modify: `app/models/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Git-rename the file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git mv app/models/participant.ts app/models/season-participant.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the file's internal symbol references**
|
||||||
|
|
||||||
|
Open `app/models/season-participant.ts`. Replace all references to renamed Drizzle exports:
|
||||||
|
- `participants` → `seasonParticipants`
|
||||||
|
- `participantResults` → `seasonParticipantResults` (if referenced)
|
||||||
|
- `participantQualifyingTotals` → `seasonParticipantQualifyingTotals` (if referenced)
|
||||||
|
|
||||||
|
Update any exported function names that embed "participant" that logically should now be "seasonParticipant" — but **only if they are window-scoped**. E.g., a function `findParticipantsForSeason` likely stays named as-is since callers in routes don't distinguish; a function `createParticipant(sportsSeasonId, data)` likely stays named. Rename only if the name would be genuinely misleading. **Default: keep exported function names to minimize route-layer churn.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `app/models/index.ts`**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```typescript
|
||||||
|
export * from "./participant";
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```typescript
|
||||||
|
export * from "./season-participant";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify file compiles in isolation**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit app/models/season-participant.ts` (may still reference other unrenamed files — acceptable as long as the error is about imports, not the rename itself.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/models/season-participant.ts app/models/index.ts
|
||||||
|
git commit -m "refactor: rename participant.ts model file to season-participant.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update all model files referencing renamed schema exports
|
||||||
|
|
||||||
|
**Files (update each to use new export names):**
|
||||||
|
- `app/models/draft-pick.ts`
|
||||||
|
- `app/models/draft-utils.ts`
|
||||||
|
- `app/models/event-result.ts`
|
||||||
|
- `app/models/group-stage-match.ts`
|
||||||
|
- `app/models/participant-result.ts`
|
||||||
|
- `app/models/qualifying-points.ts`
|
||||||
|
- `app/models/scoring-calculator.ts`
|
||||||
|
- `app/models/scoring-event.ts`
|
||||||
|
- `app/models/sports-season.ts`
|
||||||
|
- `app/models/surface-elo.ts`
|
||||||
|
- `app/models/team-score-events.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: In each file, replace imports and references**
|
||||||
|
|
||||||
|
Replacement map for imports and schema symbol references throughout the file body:
|
||||||
|
|
||||||
|
| Old symbol | New symbol |
|
||||||
|
|---|---|
|
||||||
|
| `participants` (Drizzle table) | `seasonParticipants` |
|
||||||
|
| `participantExpectedValues` | `seasonParticipantExpectedValues` |
|
||||||
|
| `participantQualifyingTotals` | `seasonParticipantQualifyingTotals` |
|
||||||
|
| `participantResults` | `seasonParticipantResults` |
|
||||||
|
| `participantSurfaceElos` | `seasonParticipantSurfaceElos` |
|
||||||
|
| `eventResults.participantId` | `eventResults.seasonParticipantId` |
|
||||||
|
| Relation access `.participant` on an `eventResults` row | `.seasonParticipant` |
|
||||||
|
|
||||||
|
**Keep unchanged:**
|
||||||
|
- Local variable names like `participant`, `participants`, `participantId` — these are runtime identifiers used by business logic; renaming them would cascade into hundreds of lines for zero benefit. Only the *schema symbol* and *Drizzle relation field* change.
|
||||||
|
- Function signatures and param names.
|
||||||
|
- JSDoc.
|
||||||
|
|
||||||
|
**Example `draft-pick.ts` update:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BEFORE
|
||||||
|
import { participants, draftPicks } from "database/schema";
|
||||||
|
const [p] = await db.select().from(participants).where(eq(participants.id, participantId));
|
||||||
|
|
||||||
|
// AFTER
|
||||||
|
import { seasonParticipants, draftPicks } from "database/schema";
|
||||||
|
const [p] = await db.select().from(seasonParticipants).where(eq(seasonParticipants.id, participantId));
|
||||||
|
// ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^ schema symbol only
|
||||||
|
// `participantId` param name stays.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run typecheck after each batch of 3 files**
|
||||||
|
|
||||||
|
Run: `npm run typecheck 2>&1 | head -40`
|
||||||
|
Expected: Errors shrink with each file updated. Stop and diagnose if error count grows.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update model test files**
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- `app/models/__tests__/sports-season.clone.test.ts`
|
||||||
|
- `app/models/__tests__/auto-pick.test.ts`
|
||||||
|
- `app/models/__tests__/executeAutoPick.timer.test.ts`
|
||||||
|
|
||||||
|
Same replacement rules. Run affected tests after update:
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/models/
|
||||||
|
git commit -m "refactor(models): update model layer to use renamed schema exports"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update all route files referencing renamed schema exports
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.expected-values.server.ts`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.surface-elo.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.simulate.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.standings.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.elo-ratings.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.golf-skills.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.regular-standings.tsx`
|
||||||
|
- `app/routes/admin.sports-seasons.$id.futures-odds.tsx`
|
||||||
|
- `app/routes/admin.data-sync.tsx`
|
||||||
|
- `app/routes/api/draft.force-manual-pick.ts`
|
||||||
|
- `app/routes/api/draft.make-pick.ts`
|
||||||
|
- `app/routes/api/draft.replace-pick.ts`
|
||||||
|
- `app/routes/api/seasons.$seasonId.draft.ts`
|
||||||
|
- `app/routes/leagues/$leagueId.draft-board.$seasonId.tsx`
|
||||||
|
- `app/routes/leagues/$leagueId.draft.$seasonId.tsx`
|
||||||
|
- `app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Apply replacement rules (same map as Task 4) to each file**
|
||||||
|
|
||||||
|
Use find-and-replace per-file. After every ~5 files, run:
|
||||||
|
|
||||||
|
Run: `npm run typecheck 2>&1 | wc -l`
|
||||||
|
(Track error count; should decrease monotonically.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify route tests still pass**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/routes`
|
||||||
|
Expected: PASS (or same-as-before count; no new failures).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/routes/
|
||||||
|
git commit -m "refactor(routes): update route layer to use renamed schema exports"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Update simulators and services
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/services/probability-updater.ts`
|
||||||
|
- `app/services/standings-sync/index.ts`
|
||||||
|
- `app/services/simulations/registry.ts`
|
||||||
|
- `app/services/simulations/simulator-config.ts`
|
||||||
|
- `app/services/simulations/types.ts`
|
||||||
|
- `app/services/simulations/afl-simulator.ts`
|
||||||
|
- `app/services/simulations/auto-racing-simulator.ts`
|
||||||
|
- `app/services/simulations/bracket-simulator.ts`
|
||||||
|
- `app/services/simulations/cs-major-simulator.ts`
|
||||||
|
- `app/services/simulations/darts-simulator.ts`
|
||||||
|
- `app/services/simulations/golf-simulator.ts`
|
||||||
|
- `app/services/simulations/llws-simulator.ts`
|
||||||
|
- `app/services/simulations/mlb-simulator.ts`
|
||||||
|
- `app/services/simulations/nba-simulator.ts`
|
||||||
|
- `app/services/simulations/ncaa-football-simulator.ts`
|
||||||
|
- `app/services/simulations/ncaam-simulator.ts`
|
||||||
|
- `app/services/simulations/ncaaw-simulator.ts`
|
||||||
|
- `app/services/simulations/nfl-simulator.ts`
|
||||||
|
- `app/services/simulations/nhl-simulator.ts`
|
||||||
|
- `app/services/simulations/snooker-simulator.ts`
|
||||||
|
- `app/services/simulations/tennis-simulator.ts`
|
||||||
|
- `app/services/simulations/ucl-simulator.ts`
|
||||||
|
- `app/services/simulations/wnba-simulator.ts`
|
||||||
|
- `app/services/simulations/world-cup-simulator.ts`
|
||||||
|
- `app/utils/sports-data-sync.server.ts`
|
||||||
|
- `server/socket.ts`
|
||||||
|
- `server/__tests__/timer-autodraft.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Apply replacement rules**
|
||||||
|
|
||||||
|
Same map as Task 4. Simulators are largely symmetric — one simulator's pattern will reveal all; batch 5 at a time and verify with typecheck.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run simulator unit tests**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/services/simulations/__tests__`
|
||||||
|
Expected: PASS. These tests consume Drizzle types extensively — a good regression canary.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS (0 errors). Any remaining errors indicate a file missed in earlier tasks.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/services/ app/utils/ server/
|
||||||
|
git commit -m "refactor(services): update simulators and services for renamed schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Full test suite + Cypress verification
|
||||||
|
|
||||||
|
**Why:** Mechanical renames are easy to get 99% right and 1% broken in a way typecheck doesn't catch — especially relation-field renames (`.participant` → `.seasonParticipant`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full unit test suite**
|
||||||
|
|
||||||
|
Run: `npm run test:run`
|
||||||
|
Expected: PASS. If failures, inspect each: a common miss is a relation accessor like `result.participant.name` that needs to become `result.seasonParticipant.name`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run a key E2E flow**
|
||||||
|
|
||||||
|
Run: `npx cypress run --spec "cypress/e2e/draft-room.cy.ts"` (or equivalent flagship flow)
|
||||||
|
Expected: PASS. E2E is the final guard — it exercises real relation loading.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke in dev**
|
||||||
|
|
||||||
|
Run: `npm run dev`
|
||||||
|
Manually:
|
||||||
|
1. Load the admin dashboard for an in-flight golf sports season — verify participant list renders.
|
||||||
|
2. Load a league's draft room — verify participant cards render.
|
||||||
|
3. Open admin surface-elo page for the tennis sports season — verify Elo values load.
|
||||||
|
|
||||||
|
Fix any broken UI before committing. (The rename may have missed a relation accessor somewhere.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final commit if any fixups were needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "refactor: fix straggler relation accessors after rename"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Verify baselines still match
|
||||||
|
|
||||||
|
**Why:** The entire point of Phase 1a is zero data change. Verify.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Re-capture and diff**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/capture-baseline.ts --out=/tmp/post-phase1a
|
||||||
|
```
|
||||||
|
(Update the script to accept `--out` or copy the baseline JSON files from `test-fixtures/baselines/` and re-run capture manually into `/tmp/post-phase1a/`.)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
diff test-fixtures/baselines/qp-totals-pre-phase1.json /tmp/post-phase1a/qp-totals-pre-phase1.json
|
||||||
|
diff test-fixtures/baselines/event-results-pre-phase1.json /tmp/post-phase1a/event-results-pre-phase1.json
|
||||||
|
diff test-fixtures/baselines/surface-elos-pre-phase1.json /tmp/post-phase1a/surface-elos-pre-phase1.json
|
||||||
|
```
|
||||||
|
Expected: All three diffs empty.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Tag the branch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag phase-1a-complete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Update documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/agents/database.md` — if it references the old table names
|
||||||
|
- Modify: `docs/agents/domain-models.md` — if it describes these tables
|
||||||
|
|
||||||
|
- [ ] **Step 1: Scan docs for old names**
|
||||||
|
|
||||||
|
Run: `grep -l "participant_qualifying_totals\|participant_expected_values\|participant_results\|participant_surface_elos\|event_results.participant_id" docs/`
|
||||||
|
Expected: list of docs to update.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update each doc to use new names**
|
||||||
|
|
||||||
|
For each file found, apply the rename map. Keep prose clear; where it helps readers, note "renamed from `X` in 2026-05".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/
|
||||||
|
git commit -m "docs: update table names in agent guides"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
Before marking this plan complete, verify:
|
||||||
|
|
||||||
|
- [ ] `git log --oneline phase-1a-complete` shows ~8 commits corresponding to Tasks 0–9.
|
||||||
|
- [ ] `grep -rn "from \"./participant\"" app/` returns zero results.
|
||||||
|
- [ ] `grep -rn "participantExpectedValues\|participantQualifyingTotals\|participantSurfaceElos" app/ database/ server/ scripts/` returns zero results (excluding string literals in UI labels, which are fine).
|
||||||
|
- [ ] `grep -rn "participantResults\b" app/ database/ server/` returns zero results.
|
||||||
|
- [ ] `npm run test:all` is green.
|
||||||
|
- [ ] `psql` confirms the renamed tables exist and the old names do not:
|
||||||
|
```
|
||||||
|
SELECT tablename FROM pg_tables WHERE tablename LIKE '%participant%' OR tablename LIKE '%season_participant%';
|
||||||
|
```
|
||||||
|
Expected: `season_participants`, `season_participant_expected_values`, `season_participant_qualifying_totals`, `season_participant_results`, `season_participant_surface_elos`. None without the `season_` prefix.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If Phase 1a must be undone:
|
||||||
|
|
||||||
|
1. `git revert` the commits from Tasks 1–9 (skip Task 0 baseline capture — keep fixtures).
|
||||||
|
2. Create a reverse migration that renames all five tables back and the column back, re-creates the old FK constraint.
|
||||||
|
3. Run `npm run db:migrate`.
|
||||||
|
|
||||||
|
Phase 1b must not begin until Phase 1a is shipped, green in production for at least 24 hours, and the tag `phase-1a-complete` is on `main`.
|
||||||
|
|
@ -0,0 +1,990 @@
|
||||||
|
# Phase 1b — Create Canonical Tables Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Create the canonical tables (`tournaments`, `participants`, `tournament_results`, `participant_surface_elos`) and add nullable FK columns (`scoring_events.tournament_id`, `season_participants.participant_id`). No code paths read or write canonical data yet — this phase is pure schema prep.
|
||||||
|
|
||||||
|
**Architecture:** Additive schema migration. New Drizzle exports + relations, new Drizzle-kit migration, new (empty) model files scaffolded with CRUD primitives so subsequent phases import them. No runtime behavior changes.
|
||||||
|
|
||||||
|
**Tech Stack:** Drizzle ORM, PostgreSQL, TypeScript, Vitest.
|
||||||
|
|
||||||
|
**Prerequisite:** Phase 1a (`2026-05-01-phase1a-rename-per-window-tables.md`) complete and shipped. Tag `phase-1a-complete` must exist.
|
||||||
|
|
||||||
|
**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 1b section and "Canonical tables" schema block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add canonical `tournaments` table to schema
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `database/schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the tournament status enum**
|
||||||
|
|
||||||
|
At the top of `database/schema.ts` alongside other enums:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const tournamentStatusEnum = pgEnum("tournament_status", [
|
||||||
|
"scheduled",
|
||||||
|
"in_progress",
|
||||||
|
"completed",
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `tournaments` table definition**
|
||||||
|
|
||||||
|
Place it near `scoringEvents` (after the existing qualifying-points-related tables in the file):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const tournaments = pgTable(
|
||||||
|
"tournaments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sportId: uuid("sport_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sports.id, { onDelete: "cascade" }),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
year: integer("year").notNull(),
|
||||||
|
startsAt: timestamp("starts_at"),
|
||||||
|
endsAt: timestamp("ends_at"),
|
||||||
|
surface: varchar("surface", { length: 50 }), // hard | clay | grass (tennis only)
|
||||||
|
location: varchar("location", { length: 255 }),
|
||||||
|
status: tournamentStatusEnum("status").notNull().default("scheduled"),
|
||||||
|
externalKey: varchar("external_key", { length: 255 }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniqueSportNameYear: uniqueIndex("tournaments_sport_name_year_unique").on(
|
||||||
|
t.sportId,
|
||||||
|
t.name,
|
||||||
|
t.year,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tournamentsRelations = relations(tournaments, ({ one, many }) => ({
|
||||||
|
sport: one(sports, {
|
||||||
|
fields: [tournaments.sportId],
|
||||||
|
references: [sports.id],
|
||||||
|
}),
|
||||||
|
scoringEvents: many(scoringEvents),
|
||||||
|
tournamentResults: many(tournamentResults),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `tournamentResults` reference will fail to resolve until Task 3 — that's expected; don't run typecheck yet.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add canonical `participants` table to schema
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `database/schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the canonical `participants` table**
|
||||||
|
|
||||||
|
Add near `seasonParticipants`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const participants = pgTable(
|
||||||
|
"participants",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sportId: uuid("sport_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sports.id, { onDelete: "cascade" }),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
externalKey: varchar("external_key", { length: 255 }),
|
||||||
|
metadata: jsonb("metadata"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniqueSportName: uniqueIndex("participants_sport_name_unique").on(
|
||||||
|
t.sportId,
|
||||||
|
t.name,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const participantsRelations = relations(participants, ({ one, many }) => ({
|
||||||
|
sport: one(sports, {
|
||||||
|
fields: [participants.sportId],
|
||||||
|
references: [sports.id],
|
||||||
|
}),
|
||||||
|
seasonParticipants: many(seasonParticipants),
|
||||||
|
tournamentResults: many(tournamentResults),
|
||||||
|
surfaceElo: one(participantSurfaceElos, {
|
||||||
|
fields: [participants.id],
|
||||||
|
references: [participantSurfaceElos.participantId],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add canonical `tournament_results` table
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `database/schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the table**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const tournamentResults = pgTable(
|
||||||
|
"tournament_results",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
tournamentId: uuid("tournament_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournaments.id, { onDelete: "cascade" }),
|
||||||
|
participantId: uuid("participant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => participants.id, { onDelete: "cascade" }),
|
||||||
|
placement: integer("placement"),
|
||||||
|
rawScore: decimal("raw_score", { precision: 10, scale: 2 }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniqueTournamentParticipant: uniqueIndex(
|
||||||
|
"tournament_results_tournament_participant_unique",
|
||||||
|
).on(t.tournamentId, t.participantId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tournamentResultsRelations = relations(tournamentResults, ({ one }) => ({
|
||||||
|
tournament: one(tournaments, {
|
||||||
|
fields: [tournamentResults.tournamentId],
|
||||||
|
references: [tournaments.id],
|
||||||
|
}),
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [tournamentResults.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add canonical `participant_surface_elos` table
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `database/schema.ts`
|
||||||
|
|
||||||
|
**Important:** The old per-window `participant_surface_elos` table was renamed to `season_participant_surface_elos` in Phase 1a. The name is free for the canonical table.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the table**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const participantSurfaceElos = pgTable(
|
||||||
|
"participant_surface_elos",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
participantId: uuid("participant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => participants.id, { onDelete: "cascade" }),
|
||||||
|
worldRanking: integer("world_ranking"),
|
||||||
|
eloHard: integer("elo_hard"),
|
||||||
|
eloClay: integer("elo_clay"),
|
||||||
|
eloGrass: integer("elo_grass"),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniqueParticipant: uniqueIndex("participant_surface_elos_participant_unique").on(
|
||||||
|
t.participantId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const participantSurfaceElosRelations = relations(
|
||||||
|
participantSurfaceElos,
|
||||||
|
({ one }) => ({
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [participantSurfaceElos.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add nullable FK columns to existing per-window tables
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `database/schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `tournament_id` to `scoringEvents`**
|
||||||
|
|
||||||
|
Inside the existing `scoringEvents` table definition, add the column (nullable for now):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Inside scoringEvents pgTable(...) columns block:
|
||||||
|
tournamentId: uuid("tournament_id").references(() => tournaments.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `scoringEventsRelations` to include:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tournament: one(tournaments, {
|
||||||
|
fields: [scoringEvents.tournamentId],
|
||||||
|
references: [tournaments.id],
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `participant_id` to `seasonParticipants`**
|
||||||
|
|
||||||
|
Inside `seasonParticipants` table definition:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
participantId: uuid("participant_id").references(() => participants.id, {
|
||||||
|
onDelete: "restrict",
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `seasonParticipantsRelations` to include:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [seasonParticipants.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run typecheck on schema**
|
||||||
|
|
||||||
|
Run: `npm run typecheck 2>&1 | head -20`
|
||||||
|
Expected: 0 errors. All new symbols are internally consistent.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the schema changes together**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add database/schema.ts
|
||||||
|
git commit -m "schema: add canonical tournament & participant tables
|
||||||
|
|
||||||
|
Adds tournaments, participants (canonical), tournament_results, and
|
||||||
|
participant_surface_elos (canonical). Adds nullable tournament_id to
|
||||||
|
scoring_events and nullable participant_id to season_participants.
|
||||||
|
Part of Phase 1b of canonical tournament layer migration."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Generate Drizzle migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `drizzle/XXXX_add_canonical_tables.sql` (drizzle-kit assigns number)
|
||||||
|
- Create: `drizzle/meta/XXXX_snapshot.json` (generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate migration**
|
||||||
|
|
||||||
|
Run: `npm run db:generate -- --name=add_canonical_tables`
|
||||||
|
Expected: Creates SQL file with `CREATE TABLE tournaments ...`, `CREATE TABLE participants ...`, `CREATE TABLE tournament_results ...`, `CREATE TABLE participant_surface_elos ...`, and `ALTER TABLE scoring_events ADD COLUMN tournament_id ...`, `ALTER TABLE season_participants ADD COLUMN participant_id ...`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inspect the generated SQL**
|
||||||
|
|
||||||
|
Open the new `drizzle/XXXX_add_canonical_tables.sql`. Verify:
|
||||||
|
- All four new tables have `CREATE TABLE` statements.
|
||||||
|
- All FKs reference the correct parent tables.
|
||||||
|
- Both `ALTER TABLE ... ADD COLUMN` statements are present for `scoring_events` and `season_participants`.
|
||||||
|
- The new columns are nullable (no `NOT NULL` clause).
|
||||||
|
- Unique indexes are created.
|
||||||
|
|
||||||
|
If anything looks off, fix the schema and re-run `npm run db:generate`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify snapshot chain**
|
||||||
|
|
||||||
|
Run: `npm run db:generate`
|
||||||
|
Expected: "No schema changes". Snapshot is consistent.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Apply migration to local DB**
|
||||||
|
|
||||||
|
Run: `npm run db:migrate`
|
||||||
|
Expected: Migration applies; verify in psql:
|
||||||
|
```
|
||||||
|
\d tournaments
|
||||||
|
\d participants
|
||||||
|
\d tournament_results
|
||||||
|
\d participant_surface_elos
|
||||||
|
\d scoring_events -- should now show tournament_id column
|
||||||
|
\d season_participants -- should now show participant_id column
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add drizzle/
|
||||||
|
git commit -m "migration: create canonical tables, add nullable FKs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Scaffold `app/models/tournament.ts` with CRUD primitives
|
||||||
|
|
||||||
|
**Why:** Phase 3 code depends on this module. Scaffolding it now in Phase 1b keeps Phase 3 focused on sync-service logic.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/models/tournament.ts`
|
||||||
|
- Create: `app/models/__tests__/tournament.test.ts`
|
||||||
|
- Modify: `app/models/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `app/models/__tests__/tournament.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import {
|
||||||
|
createTournament,
|
||||||
|
getTournamentById,
|
||||||
|
findTournamentsBySport,
|
||||||
|
updateTournamentStatus,
|
||||||
|
} from "../tournament";
|
||||||
|
import { tournaments } from "database/schema";
|
||||||
|
|
||||||
|
// Assumes a test helper that seeds a sport row and returns its id.
|
||||||
|
// If the project has a different test helper, adapt.
|
||||||
|
import { withTestSport } from "~/test/fixtures/sport";
|
||||||
|
|
||||||
|
describe("tournament model", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(tournaments);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a tournament with UNIQUE (sport, name, year)", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
const t = await createTournament({
|
||||||
|
sportId,
|
||||||
|
name: "Wimbledon",
|
||||||
|
year: 2026,
|
||||||
|
startsAt: new Date("2026-06-29"),
|
||||||
|
endsAt: new Date("2026-07-12"),
|
||||||
|
surface: "grass",
|
||||||
|
});
|
||||||
|
expect(t.id).toBeDefined();
|
||||||
|
expect(t.status).toBe("scheduled");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createTournament({ sportId, name: "Wimbledon", year: 2026 }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getTournamentById returns null if not found", async () => {
|
||||||
|
const t = await getTournamentById("00000000-0000-0000-0000-000000000000");
|
||||||
|
expect(t).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findTournamentsBySport filters by sport", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
await createTournament({ sportId, name: "Masters", year: 2026 });
|
||||||
|
await createTournament({ sportId, name: "US Open", year: 2026 });
|
||||||
|
const ts = await findTournamentsBySport(sportId);
|
||||||
|
expect(ts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateTournamentStatus advances status", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
const t = await createTournament({ sportId, name: "French Open", year: 2026 });
|
||||||
|
const updated = await updateTournamentStatus(t.id, "completed");
|
||||||
|
expect(updated.status).toBe("completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the failing test**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/tournament.test.ts`
|
||||||
|
Expected: FAIL with "Cannot find module '../tournament'".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `app/models/tournament.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import { tournaments } from "database/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type Tournament = typeof tournaments.$inferSelect;
|
||||||
|
export type NewTournament = typeof tournaments.$inferInsert;
|
||||||
|
export type TournamentStatus = Tournament["status"];
|
||||||
|
|
||||||
|
export async function createTournament(data: NewTournament): Promise<Tournament> {
|
||||||
|
const [row] = await db.insert(tournaments).values(data).returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTournamentById(id: string): Promise<Tournament | null> {
|
||||||
|
const [row] = await db.select().from(tournaments).where(eq(tournaments.id, id));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTournamentsBySport(sportId: string): Promise<Tournament[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(tournaments)
|
||||||
|
.where(eq(tournaments.sportId, sportId))
|
||||||
|
.orderBy(tournaments.year, tournaments.startsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTournamentBySportNameYear(
|
||||||
|
sportId: string,
|
||||||
|
name: string,
|
||||||
|
year: number,
|
||||||
|
): Promise<Tournament | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(tournaments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tournaments.sportId, sportId),
|
||||||
|
eq(tournaments.name, name),
|
||||||
|
eq(tournaments.year, year),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertTournament(data: NewTournament): Promise<Tournament> {
|
||||||
|
const existing = await findTournamentBySportNameYear(data.sportId, data.name, data.year);
|
||||||
|
if (existing) return existing;
|
||||||
|
return createTournament(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTournamentStatus(
|
||||||
|
id: string,
|
||||||
|
status: TournamentStatus,
|
||||||
|
): Promise<Tournament> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(tournaments)
|
||||||
|
.set({ status, updatedAt: new Date() })
|
||||||
|
.where(eq(tournaments.id, id))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/tournament.test.ts`
|
||||||
|
Expected: PASS (all 4 tests green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Export from index**
|
||||||
|
|
||||||
|
In `app/models/index.ts`, add:
|
||||||
|
```typescript
|
||||||
|
export * from "./tournament";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/models/tournament.ts app/models/__tests__/tournament.test.ts app/models/index.ts
|
||||||
|
git commit -m "feat(models): add canonical tournament CRUD module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Scaffold canonical `app/models/participant.ts`
|
||||||
|
|
||||||
|
**Note:** This file was freed by Phase 1a (old file renamed to `season-participant.ts`). Now we create a new one for the canonical entity.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/models/participant.ts`
|
||||||
|
- Create: `app/models/__tests__/participant.test.ts`
|
||||||
|
- Modify: `app/models/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `app/models/__tests__/participant.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import {
|
||||||
|
createParticipant,
|
||||||
|
getParticipantById,
|
||||||
|
findParticipantsBySport,
|
||||||
|
upsertParticipantBySportName,
|
||||||
|
} from "../participant";
|
||||||
|
import { participants } from "database/schema";
|
||||||
|
import { withTestSport } from "~/test/fixtures/sport";
|
||||||
|
|
||||||
|
describe("participant (canonical) model", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(participants);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a canonical participant", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
const p = await createParticipant({ sportId, name: "Novak Djokovic" });
|
||||||
|
expect(p.id).toBeDefined();
|
||||||
|
expect(p.name).toBe("Novak Djokovic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces UNIQUE (sport, name)", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
await createParticipant({ sportId, name: "Rory McIlroy" });
|
||||||
|
await expect(
|
||||||
|
createParticipant({ sportId, name: "Rory McIlroy" }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertParticipantBySportName is idempotent", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
const a = await upsertParticipantBySportName(sportId, "Iga Swiatek");
|
||||||
|
const b = await upsertParticipantBySportName(sportId, "Iga Swiatek");
|
||||||
|
expect(a.id).toBe(b.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getParticipantById returns null if not found", async () => {
|
||||||
|
const p = await getParticipantById("00000000-0000-0000-0000-000000000000");
|
||||||
|
expect(p).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findParticipantsBySport filters by sport", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
await createParticipant({ sportId, name: "Aryna Sabalenka" });
|
||||||
|
await createParticipant({ sportId, name: "Coco Gauff" });
|
||||||
|
const rows = await findParticipantsBySport(sportId);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/participant.test.ts`
|
||||||
|
Expected: FAIL with module not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `app/models/participant.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import { participants } from "database/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type Participant = typeof participants.$inferSelect;
|
||||||
|
export type NewParticipant = typeof participants.$inferInsert;
|
||||||
|
|
||||||
|
export async function createParticipant(
|
||||||
|
data: NewParticipant,
|
||||||
|
): Promise<Participant> {
|
||||||
|
const [row] = await db.insert(participants).values(data).returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getParticipantById(id: string): Promise<Participant | null> {
|
||||||
|
const [row] = await db.select().from(participants).where(eq(participants.id, id));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findParticipantsBySport(sportId: string): Promise<Participant[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(participants)
|
||||||
|
.where(eq(participants.sportId, sportId))
|
||||||
|
.orderBy(participants.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findParticipantBySportName(
|
||||||
|
sportId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<Participant | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(participants)
|
||||||
|
.where(and(eq(participants.sportId, sportId), eq(participants.name, name)));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertParticipantBySportName(
|
||||||
|
sportId: string,
|
||||||
|
name: string,
|
||||||
|
extra?: Partial<Omit<NewParticipant, "sportId" | "name">>,
|
||||||
|
): Promise<Participant> {
|
||||||
|
const existing = await findParticipantBySportName(sportId, name);
|
||||||
|
if (existing) return existing;
|
||||||
|
return createParticipant({ sportId, name, ...extra });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/participant.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Export from index**
|
||||||
|
|
||||||
|
In `app/models/index.ts`, add:
|
||||||
|
```typescript
|
||||||
|
export * from "./participant";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/models/participant.ts app/models/__tests__/participant.test.ts app/models/index.ts
|
||||||
|
git commit -m "feat(models): add canonical participant CRUD module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Scaffold `app/models/tournament-result.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/models/tournament-result.ts`
|
||||||
|
- Create: `app/models/__tests__/tournament-result.test.ts`
|
||||||
|
- Modify: `app/models/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `app/models/__tests__/tournament-result.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import {
|
||||||
|
upsertTournamentResult,
|
||||||
|
getTournamentResults,
|
||||||
|
getTournamentResultByParticipant,
|
||||||
|
} from "../tournament-result";
|
||||||
|
import { tournaments, participants, tournamentResults } from "database/schema";
|
||||||
|
import { withTestSport } from "~/test/fixtures/sport";
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
return await withTestSport(async (sportId) => {
|
||||||
|
const [tournament] = await db
|
||||||
|
.insert(tournaments)
|
||||||
|
.values({ sportId, name: "Wimbledon", year: 2026 })
|
||||||
|
.returning();
|
||||||
|
const [p1] = await db
|
||||||
|
.insert(participants)
|
||||||
|
.values({ sportId, name: "Novak Djokovic" })
|
||||||
|
.returning();
|
||||||
|
const [p2] = await db
|
||||||
|
.insert(participants)
|
||||||
|
.values({ sportId, name: "Carlos Alcaraz" })
|
||||||
|
.returning();
|
||||||
|
return { tournament, p1, p2 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tournament-result model", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(tournamentResults);
|
||||||
|
await db.delete(participants);
|
||||||
|
await db.delete(tournaments);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts a placement (insert path)", async () => {
|
||||||
|
const { tournament, p1 } = await seed();
|
||||||
|
const r = await upsertTournamentResult({
|
||||||
|
tournamentId: tournament.id,
|
||||||
|
participantId: p1.id,
|
||||||
|
placement: 1,
|
||||||
|
});
|
||||||
|
expect(r.placement).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts a placement (update path — idempotent)", async () => {
|
||||||
|
const { tournament, p1 } = await seed();
|
||||||
|
await upsertTournamentResult({
|
||||||
|
tournamentId: tournament.id,
|
||||||
|
participantId: p1.id,
|
||||||
|
placement: 1,
|
||||||
|
});
|
||||||
|
const updated = await upsertTournamentResult({
|
||||||
|
tournamentId: tournament.id,
|
||||||
|
participantId: p1.id,
|
||||||
|
placement: 2,
|
||||||
|
});
|
||||||
|
expect(updated.placement).toBe(2);
|
||||||
|
|
||||||
|
const all = await getTournamentResults(tournament.id);
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getTournamentResults returns all results for a tournament", async () => {
|
||||||
|
const { tournament, p1, p2 } = await seed();
|
||||||
|
await upsertTournamentResult({ tournamentId: tournament.id, participantId: p1.id, placement: 1 });
|
||||||
|
await upsertTournamentResult({ tournamentId: tournament.id, participantId: p2.id, placement: 2 });
|
||||||
|
const all = await getTournamentResults(tournament.id);
|
||||||
|
expect(all).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/tournament-result.test.ts`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `app/models/tournament-result.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import { tournamentResults } from "database/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type TournamentResult = typeof tournamentResults.$inferSelect;
|
||||||
|
export type NewTournamentResult = typeof tournamentResults.$inferInsert;
|
||||||
|
|
||||||
|
export async function upsertTournamentResult(
|
||||||
|
data: NewTournamentResult,
|
||||||
|
): Promise<TournamentResult> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(tournamentResults)
|
||||||
|
.values(data)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [tournamentResults.tournamentId, tournamentResults.participantId],
|
||||||
|
set: {
|
||||||
|
placement: data.placement ?? null,
|
||||||
|
rawScore: data.rawScore ?? null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTournamentResults(
|
||||||
|
tournamentId: string,
|
||||||
|
): Promise<TournamentResult[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(tournamentResults)
|
||||||
|
.where(eq(tournamentResults.tournamentId, tournamentId))
|
||||||
|
.orderBy(tournamentResults.placement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTournamentResultByParticipant(
|
||||||
|
tournamentId: string,
|
||||||
|
participantId: string,
|
||||||
|
): Promise<TournamentResult | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(tournamentResults)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tournamentResults.tournamentId, tournamentId),
|
||||||
|
eq(tournamentResults.participantId, participantId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/tournament-result.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Export and commit**
|
||||||
|
|
||||||
|
In `app/models/index.ts`, add:
|
||||||
|
```typescript
|
||||||
|
export * from "./tournament-result";
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
```bash
|
||||||
|
git add app/models/tournament-result.ts app/models/__tests__/tournament-result.test.ts app/models/index.ts
|
||||||
|
git commit -m "feat(models): add canonical tournament-result CRUD module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Scaffold canonical surface-elo helpers
|
||||||
|
|
||||||
|
**Note:** `app/models/surface-elo.ts` already exists (reads from the renamed `season_participant_surface_elos`). This task adds a sibling file for the canonical table. Phase 3 will switch readers over.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/models/canonical-surface-elo.ts`
|
||||||
|
- Create: `app/models/__tests__/canonical-surface-elo.test.ts`
|
||||||
|
- Modify: `app/models/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/models/__tests__/canonical-surface-elo.test.ts
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import {
|
||||||
|
upsertCanonicalSurfaceElo,
|
||||||
|
getCanonicalSurfaceElo,
|
||||||
|
} from "../canonical-surface-elo";
|
||||||
|
import { participants, participantSurfaceElos } from "database/schema";
|
||||||
|
import { withTestSport } from "~/test/fixtures/sport";
|
||||||
|
|
||||||
|
describe("canonical-surface-elo model", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(participantSurfaceElos);
|
||||||
|
await db.delete(participants);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a row", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
const [p] = await db
|
||||||
|
.insert(participants)
|
||||||
|
.values({ sportId, name: "Rafael Nadal" })
|
||||||
|
.returning();
|
||||||
|
const row = await upsertCanonicalSurfaceElo({
|
||||||
|
participantId: p.id,
|
||||||
|
eloClay: 2200,
|
||||||
|
eloHard: 2050,
|
||||||
|
eloGrass: 1950,
|
||||||
|
worldRanking: 5,
|
||||||
|
});
|
||||||
|
expect(row.eloClay).toBe(2200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts (update path)", async () => {
|
||||||
|
await withTestSport(async (sportId) => {
|
||||||
|
const [p] = await db
|
||||||
|
.insert(participants)
|
||||||
|
.values({ sportId, name: "Daniil Medvedev" })
|
||||||
|
.returning();
|
||||||
|
await upsertCanonicalSurfaceElo({ participantId: p.id, eloHard: 2100 });
|
||||||
|
const updated = await upsertCanonicalSurfaceElo({
|
||||||
|
participantId: p.id,
|
||||||
|
eloHard: 2150,
|
||||||
|
});
|
||||||
|
expect(updated.eloHard).toBe(2150);
|
||||||
|
|
||||||
|
const fetched = await getCanonicalSurfaceElo(p.id);
|
||||||
|
expect(fetched?.eloHard).toBe(2150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCanonicalSurfaceElo returns null if missing", async () => {
|
||||||
|
const row = await getCanonicalSurfaceElo("00000000-0000-0000-0000-000000000000");
|
||||||
|
expect(row).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/canonical-surface-elo.test.ts`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `app/models/canonical-surface-elo.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import { participantSurfaceElos } from "database/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type CanonicalSurfaceElo = typeof participantSurfaceElos.$inferSelect;
|
||||||
|
export type NewCanonicalSurfaceElo = typeof participantSurfaceElos.$inferInsert;
|
||||||
|
|
||||||
|
export async function upsertCanonicalSurfaceElo(
|
||||||
|
data: NewCanonicalSurfaceElo,
|
||||||
|
): Promise<CanonicalSurfaceElo> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(participantSurfaceElos)
|
||||||
|
.values(data)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [participantSurfaceElos.participantId],
|
||||||
|
set: {
|
||||||
|
worldRanking: data.worldRanking ?? null,
|
||||||
|
eloHard: data.eloHard ?? null,
|
||||||
|
eloClay: data.eloClay ?? null,
|
||||||
|
eloGrass: data.eloGrass ?? null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCanonicalSurfaceElo(
|
||||||
|
participantId: string,
|
||||||
|
): Promise<CanonicalSurfaceElo | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(participantSurfaceElos)
|
||||||
|
.where(eq(participantSurfaceElos.participantId, participantId));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- app/models/__tests__/canonical-surface-elo.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Export and commit**
|
||||||
|
|
||||||
|
In `app/models/index.ts`:
|
||||||
|
```typescript
|
||||||
|
export * from "./canonical-surface-elo";
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
```bash
|
||||||
|
git add app/models/canonical-surface-elo.ts app/models/__tests__/canonical-surface-elo.test.ts app/models/index.ts
|
||||||
|
git commit -m "feat(models): add canonical surface-elo CRUD module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `npm run test:all`
|
||||||
|
Expected: Green.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify DB schema is as expected**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql $DATABASE_URL -c "\dt" | grep -E "(tournaments|^ *participants|tournament_results|participant_surface_elos|season_participant)"
|
||||||
|
```
|
||||||
|
Expected: All of `tournaments`, `participants`, `tournament_results`, `participant_surface_elos`, `season_participants`, `season_participant_expected_values`, `season_participant_qualifying_totals`, `season_participant_results`, `season_participant_surface_elos`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify baselines still unchanged**
|
||||||
|
|
||||||
|
Re-run baseline capture to `/tmp/post-phase1b/`:
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/capture-baseline.ts --out=/tmp/post-phase1b
|
||||||
|
diff test-fixtures/baselines/ /tmp/post-phase1b/
|
||||||
|
```
|
||||||
|
Expected: Empty diff. Phase 1b adds tables but does not touch existing data.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag phase-1b-complete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- [ ] All four canonical tables exist in `database/schema.ts` with Drizzle exports and relations.
|
||||||
|
- [ ] `scoring_events.tournament_id` and `season_participants.participant_id` are nullable.
|
||||||
|
- [ ] Four new model files exist with CRUD + tests, all exported from `index.ts`.
|
||||||
|
- [ ] `npm run test:all` is green.
|
||||||
|
- [ ] Pre/post baselines diff is empty.
|
||||||
|
- [ ] Tag `phase-1b-complete` exists.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
1. `git revert` all commits in this phase.
|
||||||
|
2. Create reverse migration that drops the four new tables and the two new columns.
|
||||||
|
3. Run `npm run db:migrate`.
|
||||||
|
|
||||||
|
Phase 2 must not begin until `phase-1b-complete` is shipped and green in production for 24 hours.
|
||||||
|
|
@ -0,0 +1,978 @@
|
||||||
|
# Phase 2 — Backfill Canonical Layer Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Populate canonical tables (`tournaments`, `participants`, `tournament_results`, `participant_surface_elos`) from existing per-window rows, and set the FK columns (`scoring_events.tournament_id`, `season_participants.participant_id`). No runtime behavior changes; canonical tables become populated but are not yet read by any code path.
|
||||||
|
|
||||||
|
**Architecture:** One-off script with `--dry-run` and `--sport` flags. For each `sports_seasons` row with `scoring_pattern='qualifying_points'`, upsert canonical rows by `(sport_id, name, year)` for tournaments and `(sport_id, name)` for participants; copy completed `event_results` up to `tournament_results`; merge existing `season_participant_surface_elos` rows into canonical `participant_surface_elos`. Real run is preceded by an exact-match dry-run review.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Drizzle ORM, PostgreSQL, Vitest + test DB.
|
||||||
|
|
||||||
|
**Prerequisite:** Phase 1b complete. Tag `phase-1b-complete` exists. Baseline fixtures in `test-fixtures/baselines/` captured in Phase 1a.
|
||||||
|
|
||||||
|
**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 2 section.
|
||||||
|
|
||||||
|
**Important constraints:**
|
||||||
|
- Do not copy `qualifying_points_awarded` from `event_results` to `tournament_results`. QP stays per-window.
|
||||||
|
- Do not write `participant_qualifying_totals`-equivalent data to canonical. QP totals stay per-window.
|
||||||
|
- Abort if two windows disagree on a canonical participant's surface Elo values (not expected today; fail loud).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Write the scoring-event → tournament matcher (pure function, TDD)
|
||||||
|
|
||||||
|
**Why:** The matcher has zero DB dependency; extract and test it in isolation first.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/backfill/match-tournament.ts`
|
||||||
|
- Create: `scripts/backfill/__tests__/match-tournament.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/backfill/__tests__/match-tournament.test.ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { extractTournamentIdentity } from "../match-tournament";
|
||||||
|
|
||||||
|
describe("extractTournamentIdentity", () => {
|
||||||
|
it("extracts (name, year) from a scoring event for golf", () => {
|
||||||
|
const got = extractTournamentIdentity({
|
||||||
|
name: "Masters Tournament",
|
||||||
|
eventDate: "2026-04-09",
|
||||||
|
eventType: "major_tournament",
|
||||||
|
});
|
||||||
|
expect(got).toEqual({ name: "Masters Tournament", year: 2026 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses eventDate year when event name doesn't embed year", () => {
|
||||||
|
const got = extractTournamentIdentity({
|
||||||
|
name: "Wimbledon",
|
||||||
|
eventDate: "2026-07-01",
|
||||||
|
eventType: "major_tournament",
|
||||||
|
});
|
||||||
|
expect(got).toEqual({ name: "Wimbledon", year: 2026 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips embedded year from name if present", () => {
|
||||||
|
const got = extractTournamentIdentity({
|
||||||
|
name: "Wimbledon 2026",
|
||||||
|
eventDate: "2026-07-01",
|
||||||
|
eventType: "major_tournament",
|
||||||
|
});
|
||||||
|
expect(got).toEqual({ name: "Wimbledon", year: 2026 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if eventDate is null and name has no year", () => {
|
||||||
|
expect(() =>
|
||||||
|
extractTournamentIdentity({
|
||||||
|
name: "Wimbledon",
|
||||||
|
eventDate: null,
|
||||||
|
eventType: "major_tournament",
|
||||||
|
}),
|
||||||
|
).toThrow(/cannot determine year/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- scripts/backfill/__tests__/match-tournament.test.ts`
|
||||||
|
Expected: FAIL — module missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the pure function**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/backfill/match-tournament.ts
|
||||||
|
interface ScoringEventInput {
|
||||||
|
name: string;
|
||||||
|
eventDate: string | null;
|
||||||
|
eventType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentIdentity {
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YEAR_SUFFIX = /\s+(\d{4})\s*$/;
|
||||||
|
|
||||||
|
export function extractTournamentIdentity(ev: ScoringEventInput): TournamentIdentity {
|
||||||
|
const trimmed = ev.name.trim();
|
||||||
|
const yearMatch = trimmed.match(YEAR_SUFFIX);
|
||||||
|
let cleanName = trimmed;
|
||||||
|
let year: number | null = null;
|
||||||
|
|
||||||
|
if (yearMatch) {
|
||||||
|
year = parseInt(yearMatch[1], 10);
|
||||||
|
cleanName = trimmed.replace(YEAR_SUFFIX, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year === null && ev.eventDate) {
|
||||||
|
year = parseInt(ev.eventDate.slice(0, 4), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year === null) {
|
||||||
|
throw new Error(
|
||||||
|
`cannot determine year for event "${ev.name}" — no year in name and eventDate is null`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: cleanName, year };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify tests pass**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- scripts/backfill/__tests__/match-tournament.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/backfill/
|
||||||
|
git commit -m "feat(backfill): tournament identity extraction from scoring events"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Write the backfill orchestrator with dry-run (TDD against test DB)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/backfill-canonical-layer.ts`
|
||||||
|
- Create: `scripts/__tests__/backfill-canonical-layer.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the test — empty DB no-op**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/__tests__/backfill-canonical-layer.test.ts
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import {
|
||||||
|
tournaments,
|
||||||
|
participants,
|
||||||
|
tournamentResults,
|
||||||
|
participantSurfaceElos,
|
||||||
|
scoringEvents,
|
||||||
|
seasonParticipants,
|
||||||
|
sportsSeasons,
|
||||||
|
sports,
|
||||||
|
eventResults,
|
||||||
|
seasonParticipantSurfaceElos,
|
||||||
|
} from "database/schema";
|
||||||
|
import { runBackfill } from "../backfill-canonical-layer";
|
||||||
|
|
||||||
|
async function truncateAll() {
|
||||||
|
await db.delete(tournamentResults);
|
||||||
|
await db.delete(participantSurfaceElos);
|
||||||
|
await db.delete(participants);
|
||||||
|
await db.delete(tournaments);
|
||||||
|
await db.delete(eventResults);
|
||||||
|
await db.delete(seasonParticipantSurfaceElos);
|
||||||
|
await db.delete(seasonParticipants);
|
||||||
|
await db.delete(scoringEvents);
|
||||||
|
await db.delete(sportsSeasons);
|
||||||
|
await db.delete(sports);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("backfill-canonical-layer", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await truncateAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops on empty DB", async () => {
|
||||||
|
const result = await runBackfill({ dryRun: false });
|
||||||
|
expect(result.tournamentsCreated).toBe(0);
|
||||||
|
expect(result.participantsCreated).toBe(0);
|
||||||
|
expect(result.tournamentResultsCreated).toBe(0);
|
||||||
|
expect(result.surfaceElosCreated).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Expected: FAIL (module missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement skeleton**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/backfill-canonical-layer.ts
|
||||||
|
import { db } from "~/db/context";
|
||||||
|
import {
|
||||||
|
tournaments,
|
||||||
|
participants,
|
||||||
|
tournamentResults,
|
||||||
|
participantSurfaceElos,
|
||||||
|
scoringEvents,
|
||||||
|
seasonParticipants,
|
||||||
|
sportsSeasons,
|
||||||
|
eventResults,
|
||||||
|
seasonParticipantSurfaceElos,
|
||||||
|
} from "database/schema";
|
||||||
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
|
import { extractTournamentIdentity } from "./backfill/match-tournament";
|
||||||
|
|
||||||
|
export interface BackfillOptions {
|
||||||
|
dryRun: boolean;
|
||||||
|
sportId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackfillReport {
|
||||||
|
tournamentsCreated: number;
|
||||||
|
tournamentsLinked: number; // scoringEvents with tournament_id now set
|
||||||
|
participantsCreated: number;
|
||||||
|
participantsLinked: number; // seasonParticipants with participant_id now set
|
||||||
|
tournamentResultsCreated: number;
|
||||||
|
surfaceElosCreated: number;
|
||||||
|
warnings: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBackfill(opts: BackfillOptions): Promise<BackfillReport> {
|
||||||
|
const report: BackfillReport = {
|
||||||
|
tournamentsCreated: 0,
|
||||||
|
tournamentsLinked: 0,
|
||||||
|
participantsCreated: 0,
|
||||||
|
participantsLinked: 0,
|
||||||
|
tournamentResultsCreated: 0,
|
||||||
|
surfaceElosCreated: 0,
|
||||||
|
warnings: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eligible sports seasons
|
||||||
|
const seasons = await db
|
||||||
|
.select()
|
||||||
|
.from(sportsSeasons)
|
||||||
|
.where(eq(sportsSeasons.scoringPattern, "qualifying_points"));
|
||||||
|
|
||||||
|
for (const season of seasons) {
|
||||||
|
if (opts.sportId && season.sportId !== opts.sportId) continue;
|
||||||
|
await backfillSeason(season, opts, report);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillSeason(
|
||||||
|
season: typeof sportsSeasons.$inferSelect,
|
||||||
|
opts: BackfillOptions,
|
||||||
|
report: BackfillReport,
|
||||||
|
): Promise<void> {
|
||||||
|
// Implemented in later tasks.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify empty-DB test passes**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- scripts/__tests__/backfill-canonical-layer.test.ts`
|
||||||
|
Expected: PASS (empty DB no-op).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/backfill-canonical-layer.ts scripts/__tests__/backfill-canonical-layer.test.ts
|
||||||
|
git commit -m "feat(backfill): orchestrator skeleton with empty-DB test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Tournament + scoring-event linking
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/backfill-canonical-layer.ts`
|
||||||
|
- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing test — golf window with 4 events**
|
||||||
|
|
||||||
|
Append to the test file:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function seedGolfWindowWith4Events() {
|
||||||
|
const [sport] = await db
|
||||||
|
.insert(sports)
|
||||||
|
.values({ name: "Golf", type: "individual", simulatorType: "golf_qualifying_points" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [ss] = await db
|
||||||
|
.insert(sportsSeasons)
|
||||||
|
.values({
|
||||||
|
sportId: sport.id,
|
||||||
|
year: 2026,
|
||||||
|
scoringPattern: "qualifying_points",
|
||||||
|
totalMajors: 4,
|
||||||
|
majorsCompleted: 1,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const events = await db
|
||||||
|
.insert(scoringEvents)
|
||||||
|
.values([
|
||||||
|
{ sportsSeasonId: ss.id, name: "Masters Tournament", eventDate: "2026-04-09", eventType: "major_tournament", isQualifyingEvent: true },
|
||||||
|
{ sportsSeasonId: ss.id, name: "PGA Championship", eventDate: "2026-05-14", eventType: "major_tournament", isQualifyingEvent: true },
|
||||||
|
{ sportsSeasonId: ss.id, name: "US Open", eventDate: "2026-06-18", eventType: "major_tournament", isQualifyingEvent: true },
|
||||||
|
{ sportsSeasonId: ss.id, name: "The Open Championship", eventDate: "2026-07-16", eventType: "major_tournament", isQualifyingEvent: true },
|
||||||
|
])
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { sport, ss, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates tournaments and links scoringEvents for a golf window", async () => {
|
||||||
|
const { sport, events } = await seedGolfWindowWith4Events();
|
||||||
|
const report = await runBackfill({ dryRun: false });
|
||||||
|
|
||||||
|
expect(report.tournamentsCreated).toBe(4);
|
||||||
|
expect(report.tournamentsLinked).toBe(4);
|
||||||
|
|
||||||
|
const ts = await db.select().from(tournaments).where(eq(tournaments.sportId, sport.id));
|
||||||
|
expect(ts.map((t) => t.name).sort()).toEqual(
|
||||||
|
["Masters Tournament", "PGA Championship", "The Open Championship", "US Open"],
|
||||||
|
);
|
||||||
|
expect(ts.every((t) => t.year === 2026)).toBe(true);
|
||||||
|
|
||||||
|
const linkedEvents = await db.select().from(scoringEvents).where(eq(scoringEvents.sportsSeasonId, events[0].sportsSeasonId));
|
||||||
|
expect(linkedEvents.every((e) => e.tournamentId !== null)).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Expected: FAIL (nothing is created yet).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement tournament backfill in `backfillSeason`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function backfillSeason(season, opts, report) {
|
||||||
|
const events = await db
|
||||||
|
.select()
|
||||||
|
.from(scoringEvents)
|
||||||
|
.where(eq(scoringEvents.sportsSeasonId, season.id));
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.tournamentId) continue; // already linked
|
||||||
|
|
||||||
|
let identity;
|
||||||
|
try {
|
||||||
|
identity = extractTournamentIdentity({
|
||||||
|
name: ev.name,
|
||||||
|
eventDate: ev.eventDate,
|
||||||
|
eventType: ev.eventType,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
report.errors.push(`event ${ev.id}: ${e.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(tournaments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tournaments.sportId, season.sportId),
|
||||||
|
eq(tournaments.name, identity.name),
|
||||||
|
eq(tournaments.year, identity.year),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tournamentId: string;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
tournamentId = existing[0].id;
|
||||||
|
} else {
|
||||||
|
if (!opts.dryRun) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(tournaments)
|
||||||
|
.values({
|
||||||
|
sportId: season.sportId,
|
||||||
|
name: identity.name,
|
||||||
|
year: identity.year,
|
||||||
|
startsAt: ev.eventDate ? new Date(ev.eventDate) : null,
|
||||||
|
status: ev.eventDate && new Date(ev.eventDate) < new Date() ? "completed" : "scheduled",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
tournamentId = row.id;
|
||||||
|
} else {
|
||||||
|
tournamentId = "00000000-0000-0000-0000-000000000000"; // placeholder
|
||||||
|
}
|
||||||
|
report.tournamentsCreated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.dryRun) {
|
||||||
|
await db
|
||||||
|
.update(scoringEvents)
|
||||||
|
.set({ tournamentId })
|
||||||
|
.where(eq(scoringEvents.id, ev.id));
|
||||||
|
}
|
||||||
|
report.tournamentsLinked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- scripts/__tests__/backfill-canonical-layer.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/
|
||||||
|
git commit -m "feat(backfill): link scoring events to canonical tournaments"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Participant backfill + linking
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/backfill-canonical-layer.ts`
|
||||||
|
- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it("creates canonical participants and links season_participants", async () => {
|
||||||
|
const { sport, ss } = await seedGolfWindowWith4Events();
|
||||||
|
const [sp1] = await db
|
||||||
|
.insert(seasonParticipants)
|
||||||
|
.values({ sportsSeasonId: ss.id, name: "Rory McIlroy" })
|
||||||
|
.returning();
|
||||||
|
const [sp2] = await db
|
||||||
|
.insert(seasonParticipants)
|
||||||
|
.values({ sportsSeasonId: ss.id, name: "Scottie Scheffler" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const report = await runBackfill({ dryRun: false });
|
||||||
|
expect(report.participantsCreated).toBe(2);
|
||||||
|
expect(report.participantsLinked).toBe(2);
|
||||||
|
|
||||||
|
const canonical = await db.select().from(participants).where(eq(participants.sportId, sport.id));
|
||||||
|
expect(canonical.map((p) => p.name).sort()).toEqual(["Rory McIlroy", "Scottie Scheffler"]);
|
||||||
|
|
||||||
|
const [relinkedSP] = await db.select().from(seasonParticipants).where(eq(seasonParticipants.id, sp1.id));
|
||||||
|
expect(relinkedSP.participantId).toBe(canonical.find((p) => p.name === "Rory McIlroy")!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-running the backfill does not create duplicate canonical participants", async () => {
|
||||||
|
const { ss } = await seedGolfWindowWith4Events();
|
||||||
|
await db.insert(seasonParticipants).values({ sportsSeasonId: ss.id, name: "Jon Rahm" });
|
||||||
|
|
||||||
|
await runBackfill({ dryRun: false });
|
||||||
|
await runBackfill({ dryRun: false });
|
||||||
|
|
||||||
|
const rows = await db.select().from(participants);
|
||||||
|
expect(rows.filter((r) => r.name === "Jon Rahm")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing tests**
|
||||||
|
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `backfillSeason` with participant logic**
|
||||||
|
|
||||||
|
Append inside `backfillSeason` (after the event loop):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const seasonPs = await db
|
||||||
|
.select()
|
||||||
|
.from(seasonParticipants)
|
||||||
|
.where(eq(seasonParticipants.sportsSeasonId, season.id));
|
||||||
|
|
||||||
|
for (const sp of seasonPs) {
|
||||||
|
if (sp.participantId) continue;
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(participants)
|
||||||
|
.where(and(eq(participants.sportId, season.sportId), eq(participants.name, sp.name)));
|
||||||
|
|
||||||
|
let canonicalId: string;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
canonicalId = existing[0].id;
|
||||||
|
} else {
|
||||||
|
if (!opts.dryRun) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(participants)
|
||||||
|
.values({ sportId: season.sportId, name: sp.name })
|
||||||
|
.returning();
|
||||||
|
canonicalId = row.id;
|
||||||
|
} else {
|
||||||
|
canonicalId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
}
|
||||||
|
report.participantsCreated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.dryRun) {
|
||||||
|
await db
|
||||||
|
.update(seasonParticipants)
|
||||||
|
.set({ participantId: canonicalId })
|
||||||
|
.where(eq(seasonParticipants.id, sp.id));
|
||||||
|
}
|
||||||
|
report.participantsLinked += 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/
|
||||||
|
git commit -m "feat(backfill): canonical participants + season-participant linking"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Copy completed event results to tournament_results (Masters 2026 case)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/backfill-canonical-layer.ts`
|
||||||
|
- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing test — Masters 2026 round-trip**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it("copies completed event_results to tournament_results without touching QP", async () => {
|
||||||
|
const { ss, events } = await seedGolfWindowWith4Events();
|
||||||
|
const masters = events.find((e) => e.name === "Masters Tournament")!;
|
||||||
|
const [sp] = await db
|
||||||
|
.insert(seasonParticipants)
|
||||||
|
.values({ sportsSeasonId: ss.id, name: "Scottie Scheffler" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(eventResults).values({
|
||||||
|
scoringEventId: masters.id,
|
||||||
|
seasonParticipantId: sp.id,
|
||||||
|
placement: 1,
|
||||||
|
rawScore: "-10",
|
||||||
|
qualifyingPointsAwarded: "100.00",
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await runBackfill({ dryRun: false });
|
||||||
|
expect(report.tournamentResultsCreated).toBe(1);
|
||||||
|
|
||||||
|
const trs = await db.select().from(tournamentResults);
|
||||||
|
expect(trs).toHaveLength(1);
|
||||||
|
expect(trs[0].placement).toBe(1);
|
||||||
|
expect(trs[0].rawScore).toBe("-10");
|
||||||
|
|
||||||
|
// QP on event_results must be untouched
|
||||||
|
const [er] = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, masters.id));
|
||||||
|
expect(er.qualifyingPointsAwarded).toBe("100.00");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `backfillSeason`**
|
||||||
|
|
||||||
|
Append inside `backfillSeason` after participant linking:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Refresh events to get tournament_ids set earlier in this loop
|
||||||
|
const refreshedEvents = await db
|
||||||
|
.select()
|
||||||
|
.from(scoringEvents)
|
||||||
|
.where(eq(scoringEvents.sportsSeasonId, season.id));
|
||||||
|
|
||||||
|
for (const ev of refreshedEvents) {
|
||||||
|
if (!ev.tournamentId) continue;
|
||||||
|
const ers = await db
|
||||||
|
.select()
|
||||||
|
.from(eventResults)
|
||||||
|
.where(eq(eventResults.scoringEventId, ev.id));
|
||||||
|
|
||||||
|
for (const er of ers) {
|
||||||
|
// Find canonical participant via the season participant
|
||||||
|
const [sp] = await db
|
||||||
|
.select()
|
||||||
|
.from(seasonParticipants)
|
||||||
|
.where(eq(seasonParticipants.id, er.seasonParticipantId));
|
||||||
|
if (!sp || !sp.participantId) continue;
|
||||||
|
|
||||||
|
// Skip the zero-QP filler rows (they carry no new information)
|
||||||
|
if (er.placement === null && er.rawScore === null) continue;
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(tournamentResults)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tournamentResults.tournamentId, ev.tournamentId),
|
||||||
|
eq(tournamentResults.participantId, sp.participantId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) continue;
|
||||||
|
|
||||||
|
if (!opts.dryRun) {
|
||||||
|
await db.insert(tournamentResults).values({
|
||||||
|
tournamentId: ev.tournamentId,
|
||||||
|
participantId: sp.participantId,
|
||||||
|
placement: er.placement,
|
||||||
|
rawScore: er.rawScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
report.tournamentResultsCreated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/
|
||||||
|
git commit -m "feat(backfill): copy completed event_results into tournament_results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Surface Elo backfill (with conflict detection)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/backfill-canonical-layer.ts`
|
||||||
|
- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function seedTennisWithSurfaceElo() {
|
||||||
|
const [sport] = await db
|
||||||
|
.insert(sports)
|
||||||
|
.values({ name: "Tennis", type: "individual", simulatorType: "tennis_qualifying_points" })
|
||||||
|
.returning();
|
||||||
|
const [ss] = await db
|
||||||
|
.insert(sportsSeasons)
|
||||||
|
.values({ sportId: sport.id, year: 2026, scoringPattern: "qualifying_points", totalMajors: 4 })
|
||||||
|
.returning();
|
||||||
|
const [sp] = await db
|
||||||
|
.insert(seasonParticipants)
|
||||||
|
.values({ sportsSeasonId: ss.id, name: "Novak Djokovic" })
|
||||||
|
.returning();
|
||||||
|
await db
|
||||||
|
.insert(seasonParticipantSurfaceElos)
|
||||||
|
.values({
|
||||||
|
participantId: sp.id,
|
||||||
|
sportsSeasonId: ss.id,
|
||||||
|
eloHard: 2100,
|
||||||
|
eloClay: 2000,
|
||||||
|
eloGrass: 2050,
|
||||||
|
worldRanking: 3,
|
||||||
|
});
|
||||||
|
return { sport, ss, sp };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("backfills canonical surface elo from the single per-window row", async () => {
|
||||||
|
const { sport } = await seedTennisWithSurfaceElo();
|
||||||
|
const report = await runBackfill({ dryRun: false });
|
||||||
|
|
||||||
|
expect(report.surfaceElosCreated).toBe(1);
|
||||||
|
const canonicalPs = await db.select().from(participants).where(eq(participants.sportId, sport.id));
|
||||||
|
const [canonicalElo] = await db
|
||||||
|
.select()
|
||||||
|
.from(participantSurfaceElos)
|
||||||
|
.where(eq(participantSurfaceElos.participantId, canonicalPs[0].id));
|
||||||
|
expect(canonicalElo.eloHard).toBe(2100);
|
||||||
|
expect(canonicalElo.eloClay).toBe(2000);
|
||||||
|
expect(canonicalElo.eloGrass).toBe(2050);
|
||||||
|
expect(canonicalElo.worldRanking).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts if two windows disagree on a canonical participant's surface elo", async () => {
|
||||||
|
const { sport, ss, sp } = await seedTennisWithSurfaceElo();
|
||||||
|
|
||||||
|
// Second window with same canonical participant name but different elo
|
||||||
|
const [ss2] = await db
|
||||||
|
.insert(sportsSeasons)
|
||||||
|
.values({ sportId: sport.id, year: 2027, scoringPattern: "qualifying_points", totalMajors: 4 })
|
||||||
|
.returning();
|
||||||
|
const [sp2] = await db
|
||||||
|
.insert(seasonParticipants)
|
||||||
|
.values({ sportsSeasonId: ss2.id, name: "Novak Djokovic" })
|
||||||
|
.returning();
|
||||||
|
await db.insert(seasonParticipantSurfaceElos).values({
|
||||||
|
participantId: sp2.id,
|
||||||
|
sportsSeasonId: ss2.id,
|
||||||
|
eloHard: 2099, // different
|
||||||
|
eloClay: 2000,
|
||||||
|
eloGrass: 2050,
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await runBackfill({ dryRun: false });
|
||||||
|
expect(report.errors.some((e) => /conflict/i.test(e))).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing tests**
|
||||||
|
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `backfillSeason`**
|
||||||
|
|
||||||
|
Append to `backfillSeason`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const elos = await db
|
||||||
|
.select()
|
||||||
|
.from(seasonParticipantSurfaceElos)
|
||||||
|
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, season.id));
|
||||||
|
|
||||||
|
for (const row of elos) {
|
||||||
|
const [sp] = await db
|
||||||
|
.select()
|
||||||
|
.from(seasonParticipants)
|
||||||
|
.where(eq(seasonParticipants.id, row.participantId));
|
||||||
|
if (!sp || !sp.participantId) continue;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(participantSurfaceElos)
|
||||||
|
.where(eq(participantSurfaceElos.participantId, sp.participantId));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const mismatched =
|
||||||
|
existing.eloHard !== row.eloHard ||
|
||||||
|
existing.eloClay !== row.eloClay ||
|
||||||
|
existing.eloGrass !== row.eloGrass ||
|
||||||
|
existing.worldRanking !== row.worldRanking;
|
||||||
|
if (mismatched) {
|
||||||
|
report.errors.push(
|
||||||
|
`surface-elo conflict for canonical participant ${sp.participantId} (name=${sp.name})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.dryRun) {
|
||||||
|
await db.insert(participantSurfaceElos).values({
|
||||||
|
participantId: sp.participantId,
|
||||||
|
eloHard: row.eloHard,
|
||||||
|
eloClay: row.eloClay,
|
||||||
|
eloGrass: row.eloGrass,
|
||||||
|
worldRanking: row.worldRanking,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
report.surfaceElosCreated += 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/
|
||||||
|
git commit -m "feat(backfill): canonical surface-elo with conflict detection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: CLI wrapper + dry-run output
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/backfill-cli.ts` (CLI entry)
|
||||||
|
- Modify: `scripts/backfill-canonical-layer.ts` (optional — if the main file exported a report shape, we format it here)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the CLI**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/backfill-cli.ts
|
||||||
|
import { runBackfill, BackfillOptions } from "./backfill-canonical-layer";
|
||||||
|
|
||||||
|
function parseArgs(): BackfillOptions {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const opts: BackfillOptions = { dryRun: true };
|
||||||
|
for (const a of args) {
|
||||||
|
if (a === "--apply") opts.dryRun = false;
|
||||||
|
else if (a === "--dry-run") opts.dryRun = true;
|
||||||
|
else if (a.startsWith("--sport=")) opts.sportId = a.slice("--sport=".length);
|
||||||
|
else if (a === "--help") {
|
||||||
|
console.log("Usage: tsx scripts/backfill-cli.ts [--dry-run|--apply] [--sport=<uuid>]");
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.error(`unknown arg: ${a}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const opts = parseArgs();
|
||||||
|
console.log(`Running backfill (dryRun=${opts.dryRun}, sportId=${opts.sportId ?? "all"})`);
|
||||||
|
const report = await runBackfill(opts);
|
||||||
|
|
||||||
|
console.log("---");
|
||||||
|
console.log(`tournamentsCreated: ${report.tournamentsCreated}`);
|
||||||
|
console.log(`tournamentsLinked: ${report.tournamentsLinked}`);
|
||||||
|
console.log(`participantsCreated: ${report.participantsCreated}`);
|
||||||
|
console.log(`participantsLinked: ${report.participantsLinked}`);
|
||||||
|
console.log(`tournamentResultsCreated: ${report.tournamentResultsCreated}`);
|
||||||
|
console.log(`surfaceElosCreated: ${report.surfaceElosCreated}`);
|
||||||
|
if (report.warnings.length) {
|
||||||
|
console.log("\nWARNINGS:");
|
||||||
|
for (const w of report.warnings) console.log(` ${w}`);
|
||||||
|
}
|
||||||
|
if (report.errors.length) {
|
||||||
|
console.log("\nERRORS:");
|
||||||
|
for (const e of report.errors) console.log(` ${e}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add npm script**
|
||||||
|
|
||||||
|
In `package.json` scripts block, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"backfill:canonical": "dotenv -- tsx scripts/backfill-cli.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run dry-run locally**
|
||||||
|
|
||||||
|
Against the dev DB (loaded with `npm run db:sync-prod`):
|
||||||
|
|
||||||
|
Run: `npm run backfill:canonical -- --dry-run`
|
||||||
|
Expected: Non-zero `tournamentsCreated` etc., zero `errors`. Save stdout as `/tmp/backfill-dryrun.txt`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Inspect the dry-run output**
|
||||||
|
|
||||||
|
Review `/tmp/backfill-dryrun.txt`. For each category, sanity-check the count. For example, if we have 2 in-flight golf windows × 4 events each = 8 golf events, `tournamentsCreated` might be 4–8 depending on overlap.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/backfill-cli.ts package.json
|
||||||
|
git commit -m "feat(backfill): CLI wrapper with dry-run default"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Go/no-go verification (real run on staging)
|
||||||
|
|
||||||
|
**Human in the loop** — this task is NOT automatable.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Deploy all preceding commits to staging**
|
||||||
|
|
||||||
|
Via the project's normal deploy flow.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Capture a pre-backfill snapshot of the in-flight windows**
|
||||||
|
|
||||||
|
On staging, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/capture-baseline.ts --out=/tmp/pre-backfill-staging
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run dry-run on staging**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run backfill:canonical -- --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: zero errors; counts match expectations.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Human review of dry-run report**
|
||||||
|
|
||||||
|
Requires a human. Verify:
|
||||||
|
- Tournament names match real-world identity (no "Masters Tournament 2026" with year=null).
|
||||||
|
- Participant counts per sport are sensible.
|
||||||
|
- No conflict errors.
|
||||||
|
|
||||||
|
If anything looks wrong, fix in code (not in DB), redeploy, re-run dry-run.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run real backfill on staging**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run backfill:canonical -- --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Same counts as dry-run, zero errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify no drift on QP totals**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/capture-baseline.ts --out=/tmp/post-backfill-staging
|
||||||
|
diff /tmp/pre-backfill-staging/ /tmp/post-backfill-staging/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: **Zero differences in `qp-totals-*.json` and `event-results-*.json`**. Backfill must not touch these.
|
||||||
|
|
||||||
|
The `surface-elos-*.json` files may differ because this captures per-window Elo before Phase 2; after Phase 2 it still captures the same per-window table (`season_participant_surface_elos`), which is untouched by backfill. So diff should still be empty. If not, stop and investigate.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Spot-check Masters 2026 round-trip on staging**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql $STAGING_DB -c "
|
||||||
|
SELECT er.placement AS ev_placement, er.raw_score AS ev_score,
|
||||||
|
tr.placement AS tr_placement, tr.raw_score AS tr_score,
|
||||||
|
sp.name
|
||||||
|
FROM event_results er
|
||||||
|
JOIN scoring_events se ON se.id = er.scoring_event_id
|
||||||
|
JOIN tournaments t ON t.id = se.tournament_id
|
||||||
|
JOIN tournament_results tr ON tr.tournament_id = t.id
|
||||||
|
AND tr.participant_id = (SELECT participant_id FROM season_participants WHERE id = er.season_participant_id)
|
||||||
|
JOIN season_participants sp ON sp.id = er.season_participant_id
|
||||||
|
WHERE t.name = 'Masters Tournament' AND t.year = 2026;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Every row has `ev_placement = tr_placement` and `ev_score = tr_score`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Proceed to production only if all checks pass**
|
||||||
|
|
||||||
|
Same flow on prod: capture baseline → dry-run → human review → apply → diff → Masters spot-check.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag phase-2-complete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- [ ] `runBackfill` with `dryRun: true` writes nothing (verify via row-count diff).
|
||||||
|
- [ ] All tests in `scripts/__tests__/backfill-canonical-layer.test.ts` pass.
|
||||||
|
- [ ] After real run on prod: every `scoringEvents` row in a `qualifying_points` season has non-null `tournament_id`.
|
||||||
|
- [ ] Every `seasonParticipants` row in a `qualifying_points` season has non-null `participant_id`.
|
||||||
|
- [ ] Every completed `eventResults` row (non-null `placement` OR non-null `rawScore`) has a matching `tournamentResults` row.
|
||||||
|
- [ ] No drift in `participant_qualifying_totals` vs. baseline.
|
||||||
|
- [ ] Tag `phase-2-complete` on main.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Phase 2 is reversible because no production code reads canonical tables yet:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN;
|
||||||
|
UPDATE season_participants SET participant_id = NULL WHERE participant_id IS NOT NULL;
|
||||||
|
UPDATE scoring_events SET tournament_id = NULL WHERE tournament_id IS NOT NULL;
|
||||||
|
DELETE FROM tournament_results;
|
||||||
|
DELETE FROM participant_surface_elos;
|
||||||
|
DELETE FROM participants;
|
||||||
|
DELETE FROM tournaments;
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-run backfill after fixing root cause.
|
||||||
|
|
||||||
|
Phase 3 begins only after `phase-2-complete` has been green in production for 24 hours.
|
||||||
1272
docs/superpowers/plans/2026-05-02-phase3-canonical-cutover.md
Normal file
1272
docs/superpowers/plans/2026-05-02-phase3-canonical-cutover.md
Normal file
File diff suppressed because it is too large
Load diff
359
docs/superpowers/plans/2026-05-02-phase4-cleanup.md
Normal file
359
docs/superpowers/plans/2026-05-02-phase4-cleanup.md
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
# Phase 4 — Cleanup Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remove the per-window surface-Elo admin UI + backing table, remove the old batch-add-results wrapper route (keeping the canonical route as the only path), and strip compatibility shims. Leaves the canonical layer as the sole source of truth.
|
||||||
|
|
||||||
|
**Architecture:** Pure removal. No new behavior. Each removal is preceded by a verification step proving the removed code is no longer reachable.
|
||||||
|
|
||||||
|
**Tech Stack:** Drizzle migration, Vitest, Cypress.
|
||||||
|
|
||||||
|
**Prerequisite:** Phase 3 complete and green in production for at least 7 days with active draft activity. Tag `phase-3-complete` exists.
|
||||||
|
|
||||||
|
**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 4 section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Verify `season_participant_surface_elos` is no longer read
|
||||||
|
|
||||||
|
**Why:** Before dropping the table, prove nothing reads from it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Grep for reads**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rn "seasonParticipantSurfaceElos\|season_participant_surface_elos" app/ server/ scripts/ database/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only hits in `database/schema.ts` (the Drizzle export and relation). No app/service reads, no queries.
|
||||||
|
|
||||||
|
If any live read remains — stop. Phase 3 was incomplete; fix the read path to go canonical, redeploy, wait 24h, then return here.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Production observability check**
|
||||||
|
|
||||||
|
Check production logs and/or `pg_stat_user_tables` for activity on `season_participant_surface_elos`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT seq_scan, seq_tup_read, idx_scan, idx_tup_fetch, n_tup_upd, n_tup_del
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE relname = 'season_participant_surface_elos';
|
||||||
|
```
|
||||||
|
|
||||||
|
Check `pg_stat_reset()` was not called recently. If the table has seen reads/writes in the past 7 days (compared against a baseline), investigate before dropping.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit a note in the plan**
|
||||||
|
|
||||||
|
Paste the output of Steps 1 and 2 into a short PR description for the drop migration, documenting verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Drop `season_participant_surface_elos`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `database/schema.ts` (remove export + relations)
|
||||||
|
- Create: `drizzle/XXXX_drop_season_participant_surface_elos.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the table from schema**
|
||||||
|
|
||||||
|
In `database/schema.ts`, delete:
|
||||||
|
- The `seasonParticipantSurfaceElos` `pgTable(...)` export.
|
||||||
|
- The `seasonParticipantSurfaceElosRelations` export.
|
||||||
|
- Any references in other `*Relations` exports (e.g., in `seasonParticipantsRelations` or `sportsSeasonsRelations` that include `seasonParticipantSurfaceElos` via `many()`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Generate migration**
|
||||||
|
|
||||||
|
Run: `npm run db:generate -- --name=drop_season_participant_surface_elos`
|
||||||
|
Expected: Migration with a single `DROP TABLE "season_participant_surface_elos"` statement.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify snapshot chain**
|
||||||
|
|
||||||
|
Run: `npm run db:generate`
|
||||||
|
Expected: "No schema changes".
|
||||||
|
|
||||||
|
- [ ] **Step 4: Apply migration on staging and re-run tests**
|
||||||
|
|
||||||
|
Run: `npm run db:migrate`
|
||||||
|
Run: `npm run test:all`
|
||||||
|
Expected: Green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Apply migration on production**
|
||||||
|
|
||||||
|
Follow your normal deploy flow. After deploy, re-verify:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT tablename FROM pg_tables WHERE tablename = 'season_participant_surface_elos';
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no row returned.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add database/schema.ts drizzle/
|
||||||
|
git commit -m "migration: drop season_participant_surface_elos table
|
||||||
|
|
||||||
|
Canonical participant_surface_elos is the sole source of surface Elo
|
||||||
|
since Phase 3. No reads or writes observed in the past 7 days.
|
||||||
|
Part of Phase 4 canonical tournament layer cleanup."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Remove per-window surface-Elo admin UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Remove: `app/routes/admin.sports-seasons.$id.surface-elo.tsx`
|
||||||
|
- Modify: `app/routes.ts` (remove the route registration)
|
||||||
|
- Modify: any navigation links pointing at the removed page
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find references to the removed route**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rn "surface-elo" app/ | grep -v "^\.\/app/routes/admin\.sports-seasons\.\\\$id\.surface-elo\.tsx"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: admin navigation links + possibly dashboard; update them to point at `/admin/participants/:id/surface-elo` or the canonical participant list.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Delete the route file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm app/routes/admin.sports-seasons.$id.surface-elo.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove route registration**
|
||||||
|
|
||||||
|
In `app/routes.ts`, delete the line registering `admin.sports-seasons.$id.surface-elo.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update any links**
|
||||||
|
|
||||||
|
Edit each file found in Step 1 to redirect or remove the per-window link.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full test suite**
|
||||||
|
|
||||||
|
Run: `npm run test:all`
|
||||||
|
Expected: Green. Any failing tests that specifically asserted on the old route should be updated or removed.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/routes.ts app/
|
||||||
|
git commit -m "remove: per-window surface-elo admin UI
|
||||||
|
|
||||||
|
Canonical participant surface-elo edit page (/admin/participants/:id/surface-elo)
|
||||||
|
is now the only path. Part of Phase 4 cleanup."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Remove the batch-add-results wrapper from per-window route
|
||||||
|
|
||||||
|
**Goal:** The per-window scoring-event admin route still has a `batch-add-results` intent (added in Phase 3 as a wrapper that writes to canonical). Remove the intent so the route no longer accepts per-window result entry. Admins must use `/admin/tournaments/:id` instead.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the per-window route handling qualifying result entry (the one edited in Phase 3 Task 11; find via `grep -l "batch-add-results" app/routes/`)
|
||||||
|
- Modify: any UI component that still posts to `batch-add-results` on that route
|
||||||
|
|
||||||
|
- [ ] **Step 1: Grep for usages of the wrapper**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rn "batch-add-results" app/
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-reference with Phase 3 changes — confirm the only live callers are UI components on per-window pages (which should have been deprecated in Phase 3 release notes).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the `batch-add-results` intent branch**
|
||||||
|
|
||||||
|
In the per-window route action handler, delete the `if (intent === "batch-add-results") { ... }` branch. Other intents stay.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove the Batch Result Entry UI from the per-window event page**
|
||||||
|
|
||||||
|
Find the component that rendered the "Enter Results" form on per-window pages. Replace with a banner:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="p-4 bg-blue-50 rounded">
|
||||||
|
<p>Result entry has moved.</p>
|
||||||
|
<Link to={`/admin/tournaments/${event.tournamentId}`} className="underline">
|
||||||
|
Enter results for {event.name} here.
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
This assumes `event.tournamentId` is loaded in the route loader (it is, after Phase 1b).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove related E2E tests that exercised the old form**
|
||||||
|
|
||||||
|
Search `cypress/e2e/` for tests that used the per-window batch form. Either update to use the new canonical flow or remove if covered by `cypress/e2e/enter-tournament-result.cy.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full suite**
|
||||||
|
|
||||||
|
Run: `npm run test:all`
|
||||||
|
Expected: Green.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual smoke**
|
||||||
|
|
||||||
|
Run dev server, log in as admin, navigate to `/admin/sports-seasons/<id>/events/<eventId>` → verify the banner links to the canonical tournament page.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/
|
||||||
|
git commit -m "remove: per-window batch-add-results intent and UI
|
||||||
|
|
||||||
|
Result entry consolidated on /admin/tournaments/:id. Per-window event page
|
||||||
|
shows a deep link to the canonical flow. Part of Phase 4 cleanup."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Remove unused compatibility shims
|
||||||
|
|
||||||
|
**Goal:** Any helper functions, type aliases, or dead code paths added during Phase 3 to keep old callers working.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Grep for known shim markers**
|
||||||
|
|
||||||
|
If Phase 3 left `@deprecated` JSDoc tags, find them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "@deprecated" app/ | grep -iE "canonical|tournament|participant"
|
||||||
|
```
|
||||||
|
|
||||||
|
For each, verify no live callers remain (`grep -rn "<function-name>" app/`), then delete.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check the `intent` prop default on `BatchResultEntry`**
|
||||||
|
|
||||||
|
If Phase 3 Task 8 added an `intent` prop with default `"batch-add-results"`, change the default to `"batch-upsert-results"` now (the canonical intent) since the old one is gone. Or, if the component is now only used from the canonical route, make the prop required and remove the default.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full suite + typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run test:all`
|
||||||
|
Expected: Green.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/
|
||||||
|
git commit -m "chore: remove deprecated shims after Phase 3 cutover"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final documentation sweep
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/agents/database.md` — document canonical vs per-window split
|
||||||
|
- Modify: `docs/agents/domain-models.md`
|
||||||
|
- Create: a short section in `docs/` describing how to create a new rolling window (user-facing operator guide)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `docs/agents/database.md`**
|
||||||
|
|
||||||
|
Add a section:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
## Canonical vs. Per-Window Tables (Qualifying-Points Sports)
|
||||||
|
|
||||||
|
Golf, tennis, and CS2 use a split model:
|
||||||
|
|
||||||
|
- **Canonical** (real-world identity, shared across seasons):
|
||||||
|
- `tournaments` — real-world tournaments (Wimbledon 2026)
|
||||||
|
- `participants` — real-world players/teams (Djokovic, NAVI)
|
||||||
|
- `tournament_results` — final placements
|
||||||
|
- `participant_surface_elos` — tennis surface-specific Elo
|
||||||
|
- **Per-window** (specific to a `sports_seasons` row):
|
||||||
|
- `season_participants` — draftable roster for this window
|
||||||
|
- `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`)
|
||||||
|
|
||||||
|
Enter tournament results at `/admin/tournaments/:id`. The `syncTournamentResults`
|
||||||
|
service fans out to every window linked via `scoring_events.tournament_id`.
|
||||||
|
````
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `docs/agents/domain-models.md`**
|
||||||
|
|
||||||
|
Replace references to the old per-window participant model with the split canonical/per-window description.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create operator guide**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# docs/operations/rolling-windows.md
|
||||||
|
# Creating a Rolling Qualifying-Points Window
|
||||||
|
|
||||||
|
When a new tennis/golf/CS2 "season" should open (typically every 3 months), follow these steps:
|
||||||
|
|
||||||
|
1. Go to `/admin/tournaments` and ensure the 4 real-world tournaments exist.
|
||||||
|
If a tournament is missing, click "New Tournament" on its sport page.
|
||||||
|
2. Go to `/admin/sports-seasons/new` and pick the sport.
|
||||||
|
3. In the tournament picker, select the 4 tournaments that define this window.
|
||||||
|
4. In the roster picker, select the canonical participants you want in the draft pool.
|
||||||
|
5. Configure scoring (QP point values, roster size, etc.).
|
||||||
|
6. Submit. The new `sports_seasons` row is created with scoring_events linked to
|
||||||
|
the canonical tournaments and season_participants linked to canonical participants.
|
||||||
|
|
||||||
|
When a real-world tournament completes, enter its results once at
|
||||||
|
`/admin/tournaments/<id>` — they automatically propagate to every window that
|
||||||
|
contains it.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/
|
||||||
|
git commit -m "docs: canonical tournament layer — agent guides + operator docs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Final regression + tag
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test suite**
|
||||||
|
|
||||||
|
Run: `npm run test:all`
|
||||||
|
Expected: Green.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Baseline diff (sanity)**
|
||||||
|
|
||||||
|
Re-run baseline capture and diff against Phase 1 fixtures:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/capture-baseline.ts --out=/tmp/post-phase4
|
||||||
|
diff test-fixtures/baselines/qp-totals-pre-phase1.json /tmp/post-phase4/qp-totals-*.json
|
||||||
|
diff test-fixtures/baselines/event-results-pre-phase1.json /tmp/post-phase4/event-results-*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Empty (no QP math drift across the entire migration).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag phase-4-complete
|
||||||
|
git tag canonical-tournament-layer-complete
|
||||||
|
```
|
||||||
|
|
||||||
|
The second tag marks the full feature complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- [ ] `season_participant_surface_elos` table does not exist in prod.
|
||||||
|
- [ ] `/admin/sports-seasons/:id/surface-elo` route returns 404.
|
||||||
|
- [ ] Per-window result entry form has been replaced with a link to the canonical route.
|
||||||
|
- [ ] `grep -rn "batch-add-results" app/` returns zero results.
|
||||||
|
- [ ] `docs/agents/database.md` describes the canonical/per-window split.
|
||||||
|
- [ ] Operator guide `docs/operations/rolling-windows.md` exists.
|
||||||
|
- [ ] `npm run test:all` is green.
|
||||||
|
- [ ] Baseline diff is empty.
|
||||||
|
- [ ] Tag `canonical-tournament-layer-complete` exists on main.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Dropping `season_participant_surface_elos` is reversible if needed (no row is dropped in Phase 4 — the data was never used for anything after Phase 3). To rollback:
|
||||||
|
|
||||||
|
1. `git revert` the drop migration commit.
|
||||||
|
2. Re-generate the `season_participant_surface_elos` table via Drizzle migration.
|
||||||
|
3. Re-seed it from `participant_surface_elos` keyed via `season_participants.participantId`.
|
||||||
|
|
||||||
|
Other Phase 4 removals (routes, UI, shims) are pure code and reversible via `git revert`.
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
# Canonical Tournament & Participant Layer
|
||||||
|
|
||||||
|
**Date:** 2026-05-01
|
||||||
|
**Status:** Proposed
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Qualifying-points sports (golf, tennis, CS2) use a four-event "season" that can roll across calendar boundaries (e.g., a June 2026 tennis window needs Wimbledon 2026, US Open 2026, Australian Open 2027, French Open 2027). Today the data model ties events and participants to a single `sportsSeasons` row, which means two overlapping windows must each have their own copy of Wimbledon 2026, their own Djokovic, their own surface Elo. Any change — results, corrections, Elo adjustments — must be applied to every copy.
|
||||||
|
|
||||||
|
This design introduces a **canonical layer** above the existing per-window layer:
|
||||||
|
|
||||||
|
- **Canonical** tables own real-world identity (tournaments, participants, final results, surface Elo).
|
||||||
|
- **Per-window** tables keep their role: which tournaments a window uses, which participants its draft pool contains, and window-specific derived data (EV, QP totals, QP config).
|
||||||
|
|
||||||
|
Results entered once on the canonical tournament fan out to every window that contains it. Surface Elo edited on a canonical participant propagates automatically. Creating a new rolling window is a point-and-click composition over the canonical data.
|
||||||
|
|
||||||
|
Per-window data that is inherently window-specific (expected value under a particular 4-event set, QP running totals, QP point configuration) stays per-window — because the math depends on which 4 events you're summing over.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **In scope:** All `scoringPattern='qualifying_points'` sports — golf, tennis, CS2. The model is sport-agnostic, but migration covers these three.
|
||||||
|
- **Out of scope:** Team sports (NHL, MLB, NBA, WNBA, UCL, NCAAM/W), single-event sports (darts and snooker use only the World Championship — no rolling window problem). These keep their existing model.
|
||||||
|
- **Out of scope:** Historical fully-completed windows. No league currently has a completed qualifying-points sportsSeason, so there's nothing to backfill for reporting.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Canonical tables (new)
|
||||||
|
|
||||||
|
```
|
||||||
|
tournaments
|
||||||
|
id uuid pk
|
||||||
|
sport_id fk → sports
|
||||||
|
name varchar -- "Wimbledon"
|
||||||
|
year integer
|
||||||
|
starts_at timestamp
|
||||||
|
ends_at timestamp
|
||||||
|
surface varchar nullable -- tennis only: hard | clay | grass
|
||||||
|
location varchar nullable
|
||||||
|
status enum -- scheduled | in_progress | completed
|
||||||
|
external_key varchar nullable -- reserved for future ATP/PGA/HLTV IDs
|
||||||
|
created_at, updated_at
|
||||||
|
UNIQUE (sport_id, name, year)
|
||||||
|
|
||||||
|
participants -- RENAMED; see migration section
|
||||||
|
id uuid pk
|
||||||
|
sport_id fk → sports
|
||||||
|
name varchar
|
||||||
|
external_key varchar nullable
|
||||||
|
metadata jsonb
|
||||||
|
created_at, updated_at
|
||||||
|
UNIQUE (sport_id, name)
|
||||||
|
|
||||||
|
tournament_results
|
||||||
|
id uuid pk
|
||||||
|
tournament_id fk → tournaments
|
||||||
|
participant_id fk → participants
|
||||||
|
placement integer
|
||||||
|
raw_score decimal nullable
|
||||||
|
created_at, updated_at
|
||||||
|
UNIQUE (tournament_id, participant_id)
|
||||||
|
|
||||||
|
participant_surface_elos -- tennis-specific; replaces the existing per-window table of the same name
|
||||||
|
id uuid pk
|
||||||
|
participant_id fk → participants (canonical)
|
||||||
|
world_ranking integer nullable
|
||||||
|
elo_hard integer nullable
|
||||||
|
elo_clay integer nullable
|
||||||
|
elo_grass integer nullable
|
||||||
|
updated_at
|
||||||
|
UNIQUE (participant_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shape note:** Kept wide format (`elo_hard`/`elo_clay`/`elo_grass`) to match the existing per-window `participant_surface_elos` table, minimizing simulator read-path churn. `world_ranking` moves here because it's a real-world attribute of the participant, not a per-window value.
|
||||||
|
|
||||||
|
**Name collision handling:** The existing per-window `participant_surface_elos` table is renamed to `season_participant_surface_elos` in Phase 1 before the new canonical table of the same name is created.
|
||||||
|
|
||||||
|
### Per-window tables (renamed / extended)
|
||||||
|
|
||||||
|
The existing `participants` table is renamed to `season_participants`. Related tables follow the same renaming pattern for clarity:
|
||||||
|
|
||||||
|
| Old name | New name |
|
||||||
|
|---|---|
|
||||||
|
| `participants` | `season_participants` |
|
||||||
|
| `participant_expected_values` | `season_participant_expected_values` |
|
||||||
|
| `participant_qualifying_totals` | `season_participant_qualifying_totals` |
|
||||||
|
| `participant_results` | `season_participant_results` |
|
||||||
|
| `event_results.participant_id` | `event_results.season_participant_id` |
|
||||||
|
| `participant_surface_elos` (existing per-window) | `season_participant_surface_elos` (renamed in Phase 1); removed entirely in Phase 4 after canonical values verified |
|
||||||
|
|
||||||
|
FK additions (nullable at Phase 1, NOT NULL after Phase 2 backfill):
|
||||||
|
|
||||||
|
- `scoring_events.tournament_id` → `tournaments.id`
|
||||||
|
- `season_participants.participant_id` → `participants.id`
|
||||||
|
|
||||||
|
Unchanged: `sports_seasons`, `season_sports`, `qualifying_point_config`, `draft_picks`, `teams`, `draft_slots`, `watchlist`, scoring calculation code in `app/models/scoring-calculator.ts`, simulators' Monte Carlo logic.
|
||||||
|
|
||||||
|
### Source of truth
|
||||||
|
|
||||||
|
| Concept | Table |
|
||||||
|
|---|---|
|
||||||
|
| Tournament existence & final placements | `tournament_results` (canonical) |
|
||||||
|
| Window → tournament mapping | `scoring_events` via `tournament_id` |
|
||||||
|
| Canonical participant identity | `participants` |
|
||||||
|
| Window's draftable roster | `season_participants` |
|
||||||
|
| Per-window derived scoring (QP awarded, totals, EV) | `season_participant_*`, `event_results` |
|
||||||
|
| Surface Elo & world ranking | `participant_surface_elos` (canonical) |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Creating a new window
|
||||||
|
|
||||||
|
Admin flow has two new steps during `sports_seasons` creation for qualifying-points sports:
|
||||||
|
|
||||||
|
1. **Pick tournaments** — multi-select from `tournaments` filtered by `sport_id`. Inline create if missing. Writes `scoring_events` rows with `tournament_id` set.
|
||||||
|
2. **Pick roster** — multi-select from `participants` filtered by `sport_id`. Writes `season_participants` rows with `participant_id` set. Inline-create canonical participants if missing.
|
||||||
|
|
||||||
|
Downstream (EV upload, QP config edit, draft) is unchanged.
|
||||||
|
|
||||||
|
### Entering results (enter-once flow)
|
||||||
|
|
||||||
|
New admin route: `/admin/tournaments/:id`.
|
||||||
|
|
||||||
|
1. Admin enters placements for a canonical tournament. UI supports the same paste-and-parse flow as the existing `2026-04-12-batch-qualifying-results` design, but targets the canonical tournament.
|
||||||
|
2. Save writes `tournament_results` rows (upsert on `(tournament_id, participant_id)`).
|
||||||
|
3. `syncTournamentResults(tournamentId)` fans out:
|
||||||
|
- For each `scoring_events` row with `tournament_id = tournamentId`:
|
||||||
|
- For each participant in `tournament_results` whose canonical `participant_id` also appears in that window's `season_participants`: upsert a matching `event_results` row with `placement` and `raw_score`.
|
||||||
|
- Apply existing "fill 0-QP for non-scoring season participants" behavior per the batch qualifying results design.
|
||||||
|
- Run the existing `processQualifyingEvent` (idempotent — replaces QP, doesn't add) against that window's scoring event.
|
||||||
|
- Update `season_participant_qualifying_totals` per the existing path.
|
||||||
|
- Each window's fan-out is its own transaction. Failures are logged per-window with a retry action in the admin UI.
|
||||||
|
4. Tournament status auto-advances to `completed` on first result insert.
|
||||||
|
|
||||||
|
### Corrections
|
||||||
|
|
||||||
|
Edit `tournament_results` → re-run `syncTournamentResults`. Idempotent updates to every affected window. No stale data unless sync fails (surfaced in UI).
|
||||||
|
|
||||||
|
### Surface Elo
|
||||||
|
|
||||||
|
Read path: `app/models/surface-elo.ts`'s `getSurfaceEloMap(sportsSeasonId)` joins `season_participants → participants → participant_surface_elos` (canonical). Admin UI for surface Elo edits canonical `participant_surface_elos` directly, keyed on canonical participant. Edit affects all current and future windows containing that participant.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Four phases. Each is reversible until the next begins.
|
||||||
|
|
||||||
|
### Phase 1 — Schema (data untouched)
|
||||||
|
|
||||||
|
Two sequential Drizzle migrations, in order, to avoid name collisions between the old per-window tables and the new canonical tables:
|
||||||
|
|
||||||
|
**Phase 1a — Renames of existing tables (no new tables yet):**
|
||||||
|
- `participants` → `season_participants`
|
||||||
|
- `participant_expected_values` → `season_participant_expected_values`
|
||||||
|
- `participant_qualifying_totals` → `season_participant_qualifying_totals`
|
||||||
|
- `participant_results` → `season_participant_results`
|
||||||
|
- `participant_surface_elos` → `season_participant_surface_elos`
|
||||||
|
- `event_results.participant_id` → `event_results.season_participant_id`
|
||||||
|
|
||||||
|
Update every code reference, Drizzle relations, model file names, and type imports. Existing `app/models/participant.ts` is renamed to `app/models/season-participant.ts`. `npm run typecheck` and `npm run test:run` must pass before proceeding to Phase 1b.
|
||||||
|
|
||||||
|
**Phase 1b — New canonical tables:**
|
||||||
|
- Create `tournaments`, `participants` (canonical), `tournament_results`, `participant_surface_elos` (canonical).
|
||||||
|
- Add nullable FKs: `scoring_events.tournament_id`, `season_participants.participant_id`.
|
||||||
|
- A new `app/models/participant.ts` is created for the canonical table (the old model file was renamed in 1a, freeing the name).
|
||||||
|
|
||||||
|
No logic paths read from canonical tables yet.
|
||||||
|
|
||||||
|
Reversible: run migrations in reverse (drop canonical tables first, then reverse the renames).
|
||||||
|
|
||||||
|
### Phase 2 — Backfill
|
||||||
|
|
||||||
|
One-off script `scripts/backfill-canonical-layer.ts` with `--dry-run` and `--sport=<id>` flags.
|
||||||
|
|
||||||
|
For each `sports_seasons` where `scoring_pattern='qualifying_points'`:
|
||||||
|
|
||||||
|
1. Upsert `tournaments` rows by `(sport_id, name, year)` from the window's `scoring_events`. Set `scoring_events.tournament_id`.
|
||||||
|
2. Upsert `participants` rows by `(sport_id, name)` from the window's `season_participants`. Set `season_participants.participant_id`.
|
||||||
|
3. For each `event_results` row with `qualifying_points_awarded IS NOT NULL` (i.e., already-scored events like Masters 2026): upsert a `tournament_results` row with `placement` and `raw_score`. **Do not copy `qualifying_points_awarded`** — QP stays on `event_results` because it's window-specific.
|
||||||
|
4. For each row in `season_participant_surface_elos` (the renamed per-window table): upsert into canonical `participant_surface_elos` keyed by canonical `participant_id`, copying `elo_hard`, `elo_clay`, `elo_grass`, `world_ranking`. Abort if two windows disagree on a canonical participant's values (not expected today — fail loud if encountered).
|
||||||
|
|
||||||
|
Dry-run writes nothing; emits a CSV + summary to stdout.
|
||||||
|
|
||||||
|
Go/no-go: dry-run reviewed by maintainer, real run matches dry-run counts exactly, spot-check Masters 2026 `tournament_results` row matches its `event_results` row.
|
||||||
|
|
||||||
|
Reversible: truncate canonical tables, null out FK columns. Old code paths still functional.
|
||||||
|
|
||||||
|
### Phase 3 — Cutover
|
||||||
|
|
||||||
|
- `ALTER` `scoring_events.tournament_id` and `season_participants.participant_id` to NOT NULL.
|
||||||
|
- Implement `app/models/tournament.ts`, `app/models/participant.ts` (canonical), `app/models/tournament-result.ts`.
|
||||||
|
- Implement `app/services/sync-tournament-results.ts`.
|
||||||
|
- Add admin route `app/routes/admin.tournaments.$id.tsx` with paste-and-parse result entry.
|
||||||
|
- Add admin "create window" flow additions for tournament + roster picking.
|
||||||
|
- Switch surface Elo admin UI and `getSurfaceEloMap` to canonical read/write.
|
||||||
|
- Switch tennis simulator's surface Elo source to canonical.
|
||||||
|
- Existing per-window "Add Result" / batch entry routes become thin wrappers that write canonical + call sync. (They are removed in Phase 4.)
|
||||||
|
|
||||||
|
Feature-flag the new admin entry route; cut over during a no-draft window.
|
||||||
|
|
||||||
|
Go/no-go: new window created end-to-end in staging via canonical flow; all pre-Phase-1 baselines match post-Phase-3 values.
|
||||||
|
|
||||||
|
### Phase 4 — Cleanup
|
||||||
|
|
||||||
|
- Remove per-window result entry wrappers.
|
||||||
|
- Drop `season_participant_surface_elos` table via migration after verifying canonical `participant_surface_elos` is fully populated and all code paths read from canonical.
|
||||||
|
- Remove any compatibility shims.
|
||||||
|
|
||||||
|
## Testing Matrix
|
||||||
|
|
||||||
|
Required types: **U** unit, **M** model integration (real DB), **E** end-to-end (Cypress).
|
||||||
|
|
||||||
|
### Baselines to capture before Phase 1
|
||||||
|
|
||||||
|
1. Monte Carlo simulator output JSON for each in-flight qualifying-points window, committed as a test fixture.
|
||||||
|
2. Snapshot of `participant_qualifying_totals` (pre-rename; post-Phase-1a this is `season_participant_qualifying_totals`) per window.
|
||||||
|
3. Snapshot of each window's `event_results` for completed events (Masters 2026, any completed CS2 majors).
|
||||||
|
|
||||||
|
These are used to verify no scoring drift across phases.
|
||||||
|
|
||||||
|
### Phase 1 — Schema
|
||||||
|
|
||||||
|
| What | Where | Type | Scenarios |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Drizzle migration applies cleanly | staging run + `npm run db:migrate` | — | Forward run; re-run `db:generate` and confirm no drift |
|
||||||
|
| Renames propagate in code | `npm run typecheck`, `npm run test:run` | — | Pass |
|
||||||
|
| Relations load | existing model tests (updated for renames) | M | All pass |
|
||||||
|
|
||||||
|
### Phase 2 — Backfill script
|
||||||
|
|
||||||
|
| What | Where | Type | Scenarios |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Backfill dry-run | `scripts/__tests__/backfill-canonical-layer.test.ts` (new) | M | (a) Empty DB → no-op. (b) One in-flight golf window with 4 events incl. Masters 2026 → 4 tournaments, N canonical participants, N `tournament_results` rows. (c) Two tennis windows with overlapping participants → one canonical participant per (sport, name). (d) Conflicting canonical rows pre-existing → script aborts with clear error. (e) Dry-run writes nothing; real run matches dry-run CSV exactly. |
|
||||||
|
| Surface Elo backfill | same test file | M | All existing surface Elo rows translate to `participant_surface_elos (canonical)`; counts match; conflicts abort. |
|
||||||
|
| Masters 2026 round-trip | same test file | M | Post-backfill: `tournament_results` for Masters 2026 matches `event_results` placement/raw_score; `qualifying_points_awarded` untouched on `event_results`; `season_participant_qualifying_totals` untouched. |
|
||||||
|
| Baseline verification | `scripts/__tests__/verify-baselines.test.ts` (new) | M | `season_participant_qualifying_totals` post-backfill matches pre-Phase-1 baseline snapshot of `participant_qualifying_totals` (they should be identical — backfill doesn't touch QP). |
|
||||||
|
|
||||||
|
### Phase 3 — New code paths
|
||||||
|
|
||||||
|
| What | Where | Type | Scenarios |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app/models/tournament.ts` | `app/models/__tests__/tournament.test.ts` | M | CRUD, unique `(sport_id, name, year)`, `findBySport`, status auto-advance. |
|
||||||
|
| `app/models/participant.ts` (canonical) | `app/models/__tests__/participant.test.ts` | M | CRUD, unique `(sport_id, name)`, find-or-create. |
|
||||||
|
| `app/models/tournament-result.ts` | `app/models/__tests__/tournament-result.test.ts` | M | Upsert semantics, placement updates, idempotency. |
|
||||||
|
| `app/services/sync-tournament-results.ts` | `app/services/__tests__/sync-tournament-results.test.ts` | M | (a) Single window, roster matches all result participants → `event_results` created, QP processed. (b) Two windows share a tournament with overlapping but different rosters → only matching participants get `event_results` in each window; QP processed independently per window. (c) Participant in roster but not in `tournament_results` → 0-QP row (matches existing batch finalize). (d) Re-run with same data → idempotent, no duplicates. (e) Re-run after placement correction → all windows updated, QP re-processed. (f) One window's sync fails → others still succeed; failure logged and retryable. |
|
||||||
|
| Admin `/admin/tournaments/:id` route | `app/routes/__tests__/admin.tournaments.$id.test.tsx` (new) | M | Loader returns tournament + results; action upserts + triggers sync; non-admin → 403. |
|
||||||
|
| Admin create-window flow | `cypress/e2e/admin-create-qualifying-window.cy.ts` (new) | E | Admin picks 4 tournaments + roster → window created with FKs populated; draft room loads. |
|
||||||
|
| Result entry fan-out | `cypress/e2e/enter-tournament-result.cy.ts` (new) | E | Two windows share Wimbledon 2026; admin enters Wimbledon results once; both windows' QP standings update. |
|
||||||
|
| Surface Elo canonical | `app/models/__tests__/surface-elo.test.ts` (update) | M | Reads and writes go to canonical `participant_surface_elos`; tennis simulator receives correct values; world_ranking reads correctly. |
|
||||||
|
| Tennis simulator surface Elo | `app/services/simulations/__tests__/tennis-simulator.test.ts` (update) | U | Fed canonical surface Elo; Monte Carlo output matches baseline fixture within tolerance. |
|
||||||
|
| Back-compat wrappers for old per-window routes | existing route tests (update) | M | Writes via old routes still succeed and produce identical `event_results`. |
|
||||||
|
| Masters 2026 regression | `scripts/__tests__/verify-baselines.test.ts` | M | Post-Phase-3 QP standings for existing golf window match pre-Phase-1 baseline exactly. |
|
||||||
|
|
||||||
|
### Phase 4 — Cleanup
|
||||||
|
|
||||||
|
| What | Where | Type | Scenarios |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Old result-entry routes removed | removed test files | — | Routes return 404. |
|
||||||
|
| Drop `season_participant_surface_elos` | staging verification | — | Canonical `participant_surface_elos` fully populated; `season_participant_surface_elos` no longer read by any code path; drop migration applies cleanly. |
|
||||||
|
| Full regression | `npm run test:all` | — | Green. |
|
||||||
|
|
||||||
|
### Cross-cutting smoke tests (after every phase)
|
||||||
|
|
||||||
|
1. Load an in-flight golf draft room → Masters 2026 results render, QP standings match baseline.
|
||||||
|
2. Run tennis simulator for an in-flight window → Monte Carlo output matches baseline fixture within tolerance.
|
||||||
|
3. Create a fresh draft for an existing window → draft picks save (no NULL FK errors).
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
**R1. Name collision for canonical participants.** `UNIQUE (sport_id, name)` is correct for today's roster sizes. `external_key` reserved for future disambiguation via ATP/PGA/HLTV IDs.
|
||||||
|
|
||||||
|
**R2. CS2 team-name churn.** Rebranded team is a new canonical participant. Intentional — matches current fantasy semantics.
|
||||||
|
|
||||||
|
**R3. Masters 2026 double-scoring.** `processQualifyingEvent` is already idempotent (replaces QP). Explicit test in Phase 2 + Phase 3 matrix.
|
||||||
|
|
||||||
|
**R4. Sync fan-out partial failure.** Per-window transactions. Each window logs success/failure independently. Admin UI shows "synced to X of N windows" with per-window retry.
|
||||||
|
|
||||||
|
**R5. Simulator output drift.** Baseline Monte Carlo output captured before Phase 1 and compared after each subsequent phase.
|
||||||
|
|
||||||
|
**R6. Draft-in-progress during cutover.** Phase 3 deployed behind a feature flag, enabled during a no-draft window.
|
||||||
|
|
||||||
|
**R7. Admin muscle memory.** Old routes stay as wrappers through Phase 3; removed in Phase 4. Release notes accompany Phase 3 cutover.
|
||||||
|
|
||||||
|
## Out of Scope / Flagged for Later
|
||||||
|
|
||||||
|
- Backfill of fully-completed historical windows: no such windows exist today.
|
||||||
|
- Automated import of tournament results from external feeds (ATP/PGA APIs). `external_key` column reserves space for this.
|
||||||
|
- Cross-sport disambiguation for identical names (extremely rare; punted until real).
|
||||||
|
- Merge tool for canonical participants if a duplicate is created accidentally (can be added later; not a v1 blocker given admin-only data entry).
|
||||||
Loading…
Add table
Reference in a new issue