brackt/docs/superpowers/plans/2026-05-01-phase1b-create-canonical-tables.md
Chris Parsons 81af8907cc
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>
2026-05-01 05:20:34 +00:00

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