# 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 { const [row] = await db.insert(tournaments).values(data).returning(); return row; } export async function getTournamentById(id: string): Promise { const [row] = await db.select().from(tournaments).where(eq(tournaments.id, id)); return row ?? null; } export async function findTournamentsBySport(sportId: string): Promise { 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 { 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 { 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 { 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 { const [row] = await db.insert(participants).values(data).returning(); return row; } export async function getParticipantById(id: string): Promise { const [row] = await db.select().from(participants).where(eq(participants.id, id)); return row ?? null; } export async function findParticipantsBySport(sportId: string): Promise { return db .select() .from(participants) .where(eq(participants.sportId, sportId)) .orderBy(participants.name); } export async function findParticipantBySportName( sportId: string, name: string, ): Promise { 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>, ): Promise { 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 { 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 { return db .select() .from(tournamentResults) .where(eq(tournamentResults.tournamentId, tournamentId)) .orderBy(tournamentResults.placement); } export async function getTournamentResultByParticipant( tournamentId: string, participantId: string, ): Promise { 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 { 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 { 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.