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:
Chris Parsons 2026-05-01 05:20:34 +00:00
parent 3611f5620e
commit 81af8907cc
No known key found for this signature in database
6 changed files with 4524 additions and 0 deletions

View file

@ -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 09.
- [ ] `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 19 (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`.

View file

@ -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.

View file

@ -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 48 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.

File diff suppressed because it is too large Load diff

View 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`.

View file

@ -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).