991 lines
29 KiB
Markdown
991 lines
29 KiB
Markdown
|
|
# 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.
|