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