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

29 KiB

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:

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):

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:

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

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
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):

// Inside scoringEvents pgTable(...) columns block:
tournamentId: uuid("tournament_id").references(() => tournaments.id, {
  onDelete: "set null",
}),

Update scoringEventsRelations to include:

tournament: one(tournaments, {
  fields: [scoringEvents.tournamentId],
  references: [tournaments.id],
}),
  • Step 2: Add participant_id to seasonParticipants

Inside seasonParticipants table definition:

participantId: uuid("participant_id").references(() => participants.id, {
  onDelete: "restrict",
}),

Update seasonParticipantsRelations to include:

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
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
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:

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
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:

export * from "./tournament";
  • Step 6: Commit
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:

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
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:

export * from "./participant";
  • Step 6: Commit
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:

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
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:

export * from "./tournament-result";

Commit:

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

// 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
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:

export * from "./canonical-surface-elo";

Commit:

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
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/:

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