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>
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
tournamentstable 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
participantstable
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_idtoscoringEvents
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_idtoseasonParticipants
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 TABLEstatements. - All FKs reference the correct parent tables.
- Both
ALTER TABLE ... ADD COLUMNstatements are present forscoring_eventsandseason_participants. - The new columns are nullable (no
NOT NULLclause). - 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.tswith Drizzle exports and relations. scoring_events.tournament_idandseason_participants.participant_idare nullable.- Four new model files exist with CRUD + tests, all exported from
index.ts. npm run test:allis green.- Pre/post baselines diff is empty.
- Tag
phase-1b-completeexists.
Rollback
git revertall commits in this phase.- Create reverse migration that drops the four new tables and the two new columns.
- Run
npm run db:migrate.
Phase 2 must not begin until phase-1b-complete is shipped and green in production for 24 hours.