diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2c46d41..ff7fc01d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: runs-on: ubuntu-latest outputs: server: ${{ steps.filter.outputs.server }} + worker: ${{ steps.filter.outputs.worker }} dashboard: ${{ steps.filter.outputs.dashboard }} collection: ${{ steps.filter.outputs.collection }} website: ${{ steps.filter.outputs.website }} @@ -25,6 +26,10 @@ jobs: id: filter with: filters: | + worker: + - 'apps/worker/**' + - 'packages/db/**' + - '.github/workflows/ci.yml' server: - 'apps/server/**' - 'packages/db/**' @@ -147,6 +152,51 @@ jobs: with: path: apps/server/test-report/ + worker: + name: Worker + needs: changes + if: ${{ needs.changes.outputs.worker == 'true' }} + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: lovat_test + POSTGRES_PASSWORD: lovat_test + POSTGRES_DB: lovat_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U lovat_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + defaults: + run: + working-directory: apps/worker + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.20.0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: npm --prefix ../../packages/db ci --include=dev + - run: npm --prefix ../../packages/db run build + - run: bun install --frozen-lockfile --ignore-scripts + - run: bun run typecheck + - name: Test worker against disposable PostgreSQL + env: + DATABASE_URL: postgresql://lovat_test:lovat_test@127.0.0.1:5432/lovat_test + LOVAT_DB_TEST: "1" + run: | + npm --prefix ../../packages/db run db:deploy + bun run test + - name: Build worker deployment image + working-directory: . + run: docker build -f apps/worker/Dockerfile -t lovat-worker:ci . + test-report: name: Publish test report needs: [server, gate] @@ -270,12 +320,24 @@ jobs: gate: name: Monorepo CI if: ${{ always() }} - needs: [changes, structure, server, collection, dashboard-format, dashboard, website, learn] + needs: + [ + changes, + structure, + server, + worker, + collection, + dashboard-format, + dashboard, + website, + learn, + ] runs-on: ubuntu-latest env: CHANGES_RESULT: ${{ needs.changes.result }} STRUCTURE_RESULT: ${{ needs.structure.result }} SERVER_RESULT: ${{ needs.server.result }} + WORKER_RESULT: ${{ needs.worker.result }} COLLECTION_RESULT: ${{ needs.collection.result }} WEBSITE_RESULT: ${{ needs.website.result }} LEARN_RESULT: ${{ needs.learn.result }} @@ -288,6 +350,7 @@ jobs: "$CHANGES_RESULT" \ "$STRUCTURE_RESULT" \ "$SERVER_RESULT" \ + "$WORKER_RESULT" \ "$COLLECTION_RESULT" \ "$DASHBOARD_FORMAT_RESULT" \ "$DASHBOARD_RESULT" \ diff --git a/apps/server/src/handler/manager/addTournamentMatches.ts b/apps/server/src/handler/manager/addTournamentMatches.ts index 98fb3aa6..d08ff8ec 100644 --- a/apps/server/src/handler/manager/addTournamentMatches.ts +++ b/apps/server/src/handler/manager/addTournamentMatches.ts @@ -1,274 +1,27 @@ import prismaClient from "../../prismaClient.js"; -import z from "zod"; -import axios from "axios"; -import type { AxiosResponse } from "axios"; - -interface TbaAlliance { - team_keys: string[]; -} - -interface TbaMatch { - actual_time: number | null; - time: number | null; - comp_level: string; - match_number: number; - key: string; - alliances: { - red: TbaAlliance; - blue: TbaAlliance; - }; -} - -type TbaMatchesResponse = TbaMatch[]; +// Keep existing callers, but let the worker be the only official match writer. export const addTournamentMatches = async ( tournamentKey: string, ): Promise => { try { - if (tournamentKey === undefined) { - throw "tournament key is undefined"; - } - - if (!tournamentKey.startsWith("2026")) { - return; - } - - const url = "https://www.thebluealliance.com/api/v3"; - const tournamentRow = await prismaClient.tournament.findUnique({ - where: { - key: tournamentKey, - }, - }); - - if (tournamentRow === null) { - throw "tournament not found when trying to insert tournament matches"; - } + if (typeof tournamentKey !== "string" || !tournamentKey.trim()) return; - const eventResponse = await fetch(`${url}/event/${tournamentKey}`, { - headers: { "X-TBA-Auth-Key": process.env.TBA_KEY ?? "" }, + const tournament = await prismaClient.tournament.findUnique({ + where: { key: tournamentKey }, + select: { key: true }, }); - const json: unknown = await eventResponse.json(); + if (!tournament) return; - console.log(JSON.stringify(json, null, 2)); - - const event = z - .object({ - remap_teams: z.record(z.string(), z.string()).nullish(), - }) - .passthrough() - .parse(json); - - const remap_teams = event.remap_teams ?? {}; - let matchesResponse: AxiosResponse; - try { - matchesResponse = await axios.get( - `${url}/event/${tournamentKey}/matches`, - { - headers: { - "X-TBA-Auth-Key": process.env.TBA_KEY, - "If-None-Match": tournamentRow.latestFetchETag ?? "", - }, - }, - ); - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 304) { - return; - } else { - throw error; - } - } - - await prismaClient.tournament.update({ - where: { - key: tournamentKey, - }, - data: { - latestFetchETag: matchesResponse.headers.etag, - }, + await prismaClient.importJob.upsert({ + where: { kind_targetKey: { kind: "matches", targetKey: tournamentKey } }, + // Preserve the worker's active lease, refresh cadence and retry backoff. + update: {}, + create: { kind: "matches", targetKey: tournamentKey, runAt: new Date() }, }); - - // playoff formats come from tba's event.playoff_type - // Double Elim 8 team is 10 - // Double Elim 4 team is 11 - // as per https://github.com/the-blue-alliance/the-blue-alliance/blob/main/src/backend/common/consts/playoff_type.py - - const eightTeamDoubleElimPlayoffMatchOrder = new Map([ - ["sf1m1", 1], - ["sf2m1", 2], - ["sf3m1", 3], - ["sf4m1", 4], - ["sf5m1", 5], - ["sf6m1", 6], - ["sf7m1", 7], - ["sf8m1", 8], - ["sf9m1", 9], - ["sf10m1", 10], - ["sf11m1", 11], - ["sf12m1", 12], - ["sf13m1", 13], - ["f1m1", 14], - ["f1m2", 15], - ]); - - const fourTeamDoubleElimPlayoffMatchOrder = new Map([ - ["sf1m1", 1], - ["sf2m1", 2], - ["sf3m1", 3], - ["sf4m1", 4], - ["sf5m1", 5], - ["f1m1", 6], - ["f1m2", 7], - ]); - - const playoffMatchOrder = - event.playoff_type === 10 - ? eightTeamDoubleElimPlayoffMatchOrder - : fourTeamDoubleElimPlayoffMatchOrder; - - // For each match in the tournament - matchesResponse.data.sort( - (a: TbaMatch, b: TbaMatch) => - (a.actual_time ?? a.time ?? 0) - (b.actual_time ?? b.time ?? 0), - ); - - for (const match of matchesResponse.data) { - if (match.comp_level == "qm") { - //all teams in the match - const teams = [ - ...match.alliances.red.team_keys, - ...match.alliances.blue.team_keys, - ]; - let matchesString = ``; - //make matches with trailing _0, _1, _2 etc - for (let k = 0; k < teams.length; k++) { - matchesString = - matchesString + - `('${tournamentKey}_qm${match.match_number}_${k}', '${tournamentKey}', ${match.match_number}, '${teams[k]}', '${match.comp_level}'), `; - const currMatchKey = `${tournamentKey}_qm${match.match_number}_${k}`; - - const fakeTeamKey = teams[k]; // The one TBA sends you which is potentially "fake", like frc6418B - const mapEntry = Object.entries(remap_teams).find( - (v) => v[1] === fakeTeamKey, - ); - const realTeamKey = mapEntry ? mapEntry[0] : fakeTeamKey; - const currTeam = Number(realTeamKey.substring(3)); - - const params = z - .object({ - matchNumber: z.number(), - tournamentKey: z.string(), - key: z.string(), - teamNumber: z.number(), - }) - .safeParse({ - key: currMatchKey, - tournamentKey: tournamentKey, - matchNumber: match.match_number, - teamNumber: currTeam, - }); - - if (!params.success) { - throw params; - } - - //cant use currMatch key bc theres an issue with the enum - await prismaClient.teamMatchData.upsert({ - where: { - key: currMatchKey, - }, - update: { - tournamentKey: params.data.tournamentKey, - matchNumber: params.data.matchNumber, - teamNumber: params.data.teamNumber, - matchType: "QUALIFICATION", - }, - create: { - key: params.data.key, - tournamentKey: params.data.tournamentKey, - matchNumber: params.data.matchNumber, - teamNumber: params.data.teamNumber, - matchType: "QUALIFICATION", - }, - }); - } - } else { - const teams = [ - ...match.alliances.red.team_keys, - ...match.alliances.blue.team_keys, - ]; - - if (teams.length !== 6) { - continue; - } - - const mappedTeams: number[] = []; - let allTeamsKnown = true; - for (const teamKey of teams) { - const mapEntry = Object.entries(remap_teams).find( - (v) => v[1] === teamKey, - ); - const realTeamKey = mapEntry ? mapEntry[0] : teamKey; - const teamNumber = Number(realTeamKey.substring(3)); - if (!Number.isFinite(teamNumber) || teamNumber <= 0) { - allTeamsKnown = false; - break; - } - mappedTeams.push(teamNumber); - } - - if (!allTeamsKnown) { - continue; - } - - const matchSuffix = match.key.split("_")[1] ?? ""; - const matchNumber = playoffMatchOrder.get(matchSuffix); - if (!matchNumber) { - continue; - } - - for (let k = 0; k < 6; k++) { - const currTeam = mappedTeams[k]; - - const currMatchKey = `${tournamentKey}_em${matchNumber}_${k}`; - - const params = z - .object({ - matchNumber: z.number(), - tournamentKey: z.string(), - key: z.string(), - teamNumber: z.number(), - }) - .parse({ - key: currMatchKey, - tournamentKey: tournamentKey, - matchNumber: matchNumber, - teamNumber: currTeam, - }); - - //cant use currMatch key bc theres an issue with the enum - await prismaClient.teamMatchData.upsert({ - where: { - key: currMatchKey, - }, - update: { - tournamentKey: params.tournamentKey, - matchNumber: params.matchNumber, - teamNumber: params.teamNumber, - matchType: "ELIMINATION", - }, - create: { - key: params.key, - tournamentKey: params.tournamentKey, - matchNumber: params.matchNumber, - teamNumber: params.teamNumber, - matchType: "ELIMINATION", - }, - }); - } - } - } - } catch (error) { - console.log(error); + } catch { + // A failed enqueue must not prevent callers from reading stored matches. + console.error("Unable to queue tournament match refresh"); } }; diff --git a/apps/server/test/analysisMetrics.integration.test.ts b/apps/server/test/analysisMetrics.integration.test.ts index fcd55797..c584d8e0 100644 --- a/apps/server/test/analysisMetrics.integration.test.ts +++ b/apps/server/test/analysisMetrics.integration.test.ts @@ -34,6 +34,7 @@ const fixtureId = randomUUID(); const sourceTeam = -Math.floor(Math.random() * 1_000_000_000) - 1; const otherSourceTeam = sourceTeam - 1; const robot = sourceTeam - 2; +const robotTeams = [robot]; const missingRobot = sourceTeam - 3; const tournamentKey = `metrics-${fixtureId}`; const hiddenTournament = `hidden-${fixtureId}`; @@ -112,6 +113,10 @@ beforeAll(async () => { await db.tournament.create({ data: { key, name: "Synthetic metric tournament" }, }); + await db.team.create({ + data: { number: robot, name: "Synthetic analysis robot" }, + }); + await db.teamMatchData.create({ data: { key: matchKey, @@ -173,6 +178,8 @@ afterAll(async () => { await db.tournament.deleteMany({ where: { key: { in: fixtureTournaments } }, }); + await db.team.deleteMany({ where: { number: { in: robotTeams } } }); + await closeRedis(); await db.$disconnect(); }); @@ -437,6 +444,11 @@ const edgeReport = async ( edgeEvents: typeof events, ) => { const team = robot - edgeMatchNumber++; + await db.team.create({ + data: { number: team, name: "Synthetic analysis edge robot" }, + }); + robotTeams.push(team); + await db.teamMatchData.create({ data: { key: `edge-${team}-${fixtureId}`, diff --git a/apps/server/test/manager.integration.test.ts b/apps/server/test/manager.integration.test.ts index 00ccd180..c411c4b8 100644 --- a/apps/server/test/manager.integration.test.ts +++ b/apps/server/test/manager.integration.test.ts @@ -81,6 +81,10 @@ const fixtureId = randomUUID(); const teamNumber = -Math.floor(Math.random() * 1_000_000_000) - 1; const otherTeamNumber = teamNumber - 1; const unverifiedTeamNumber = teamNumber - 2; +const robotTeamNumbers = Array.from( + { length: 6 }, + (_, slot) => teamNumber - 3 - slot, +); const userIds = Object.fromEntries( ["owner", "peer", "lead", "otherLead", "noTeam", "unverified"].map((name) => [ name, @@ -103,6 +107,13 @@ beforeAll(async () => { }, }); } + await db.team.createMany({ + data: robotTeamNumbers.map((number) => ({ + number, + name: "Synthetic match result robot", + })), + }); + await db.tournament.create({ data: { key: tournamentKey, name: "Synthetic picklist tournament" }, }); @@ -136,6 +147,10 @@ afterAll(async () => { }, }); await db.tournament.deleteMany({ where: { key: tournamentKey } }); + await db.team.deleteMany({ + where: { number: { in: robotTeamNumbers } }, + }); + await kv.del([ ...rateKeys, ...[teamNumber, otherTeamNumber, unverifiedTeamNumber].map( @@ -623,7 +638,7 @@ it("filters match-result reports by the authenticated viewer's source rules", as await db.teamMatchData.create({ data: { key: `${matchKey}_${slot}`, - teamNumber: slot + 1, + teamNumber: robotTeamNumbers[slot], tournamentKey, matchNumber: 1, matchType: "QUALIFICATION", diff --git a/apps/server/test/matchImports.unit.test.ts b/apps/server/test/matchImports.unit.test.ts index b9c878f6..fa97ebea 100644 --- a/apps/server/test/matchImports.unit.test.ts +++ b/apps/server/test/matchImports.unit.test.ts @@ -1,229 +1,67 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; + const mocks = vi.hoisted(() => ({ - http: vi.fn(), - isAxiosError: vi.fn(), - fetch: vi.fn(), tournament: { findUnique: vi.fn(), update: vi.fn() }, teamMatchData: { upsert: vi.fn() }, + importJob: { upsert: vi.fn() }, })); -vi.mock("axios", () => ({ - default: { get: mocks.http, isAxiosError: mocks.isAxiosError }, -})); -vi.mock("../src/prismaClient.js", () => ({ - default: { tournament: mocks.tournament, teamMatchData: mocks.teamMatchData }, -})); + +vi.mock("../src/prismaClient.js", () => ({ default: mocks })); + import { addTournamentMatches } from "../src/handler/manager/addTournamentMatches.js"; -const match = (key = "2026test_qm1", level = "qm") => ({ - key, - comp_level: level, - match_number: 1, - actual_time: 10, - time: 20, - alliances: { - red: { team_keys: ["frc1B", "frc2", "frc3"] }, - blue: { team_keys: ["frc4", "frc5", "frc6"] }, - }, -}); -let matches: ReturnType[]; + beforeEach(() => { vi.resetAllMocks(); - vi.stubGlobal("fetch", mocks.fetch); - vi.spyOn(console, "log").mockImplementation(() => undefined); - mocks.tournament.findUnique.mockResolvedValue({ - latestFetchETag: "old-etag", - }); - mocks.fetch.mockResolvedValue({ - json: async () => ({ remap_teams: { frc1: "frc1B" }, playoff_type: 10 }), - }); - matches = [match()]; - mocks.http.mockImplementation(async () => ({ - data: matches, - headers: { etag: "new-etag" }, - })); - vi.stubEnv("TBA_KEY", "synthetic-key"); -}); -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); + mocks.tournament.findUnique.mockResolvedValue({ key: "2026test" }); }); -it("remaps qualification teams and upserts all six positions with an ETag", async () => { + +afterEach(() => vi.restoreAllMocks()); + +it("queues worker-owned refreshes without changing participants or legacy validators", async () => { await addTournamentMatches("2026test"); - expect(mocks.http).toHaveBeenCalledWith( - "https://www.thebluealliance.com/api/v3/event/2026test/matches", - { - headers: { - "X-TBA-Auth-Key": "synthetic-key", - "If-None-Match": "old-etag", - }, - }, - ); - expect(mocks.tournament.update).toHaveBeenCalledWith({ - where: { key: "2026test" }, - data: { latestFetchETag: "new-etag" }, - }); - expect(mocks.teamMatchData.upsert).toHaveBeenCalledTimes(6); - expect(mocks.teamMatchData.upsert).toHaveBeenCalledWith({ - where: { key: "2026test_qm1_0" }, - update: { - tournamentKey: "2026test", - matchNumber: 1, - teamNumber: 1, - matchType: "QUALIFICATION", - }, - create: { - key: "2026test_qm1_0", - tournamentKey: "2026test", - matchNumber: 1, - teamNumber: 1, - matchType: "QUALIFICATION", - }, + + expect(mocks.importJob.upsert).toHaveBeenCalledWith({ + where: { kind_targetKey: { kind: "matches", targetKey: "2026test" } }, + update: {}, + create: { kind: "matches", targetKey: "2026test", runAt: expect.any(Date) }, }); -}); -it.each([10, 11])( - "maps playoff final ordinals for the event format (%s)", - async (playoff_type) => { - matches = [match("2026test_f1m1", "f")]; - mocks.fetch.mockResolvedValue({ - json: async () => ({ remap_teams: { frc1: "frc1B" }, playoff_type }), - }); - await addTournamentMatches("2026test"); - const ordinal = playoff_type === 10 ? 14 : 6; - expect(mocks.teamMatchData.upsert).toHaveBeenCalledWith({ - where: { key: `2026test_em${ordinal}_0` }, - update: { - tournamentKey: "2026test", - matchNumber: ordinal, - teamNumber: 1, - matchType: "ELIMINATION", - }, - create: { - key: `2026test_em${ordinal}_0`, - tournamentKey: "2026test", - matchNumber: ordinal, - teamNumber: 1, - matchType: "ELIMINATION", - }, - }); - }, -); -it.each([undefined, null])( - "imports unmapped teams when remapping is absent (%s)", - async (remap_teams) => { - mocks.tournament.findUnique.mockResolvedValue({ latestFetchETag: null }); - vi.stubEnv("TBA_KEY", undefined); - mocks.fetch.mockResolvedValue({ - json: async () => ({ remap_teams, playoff_type: 11 }), - }); - matches = [ - { - ...match(), - actual_time: null as unknown as number, - time: null as unknown as number, - alliances: { - red: { team_keys: ["frc1", "frc2", "frc3"] }, - blue: { team_keys: ["frc4", "frc5", "frc6"] }, - }, - }, - { - ...match(), - actual_time: null as unknown as number, - time: 20, - match_number: 2, - }, - ]; - await addTournamentMatches("2026test"); - expect(mocks.teamMatchData.upsert).toHaveBeenCalled(); - expect(mocks.fetch).toHaveBeenCalledWith(expect.any(String), { - headers: { "X-TBA-Auth-Key": "" }, - }); - }, -); -it.each(["frcBAD", "frc0", "frc-1"])( - "skips playoffs with unknown or invalid teams (%s)", - async (teamKey) => { - matches = [ - { - ...match("2026test_sf1m1", "sf"), - alliances: { - red: { team_keys: [teamKey, "frc2", "frc3"] }, - blue: { team_keys: ["frc4", "frc5", "frc6"] }, - }, - }, - ]; - await addTournamentMatches("2026test"); - expect(mocks.teamMatchData.upsert).not.toHaveBeenCalled(); - }, -); -it("skips incomplete alliances and unmapped playoff matches", async () => { - matches = [ - { - ...match("2026test_sf1m1", "sf"), - alliances: { red: { team_keys: [] }, blue: { team_keys: [] } }, - }, - match("2026test_qf1m1", "qf"), - match("missing-suffix", "sf"), - ]; - await addTournamentMatches("2026test"); expect(mocks.teamMatchData.upsert).not.toHaveBeenCalled(); -}); -it("stops invalid qualification imports before writing a bad team number", async () => { - mocks.fetch.mockResolvedValue({ json: async () => ({}) }); - await addTournamentMatches("2026test"); - expect(mocks.teamMatchData.upsert).not.toHaveBeenCalled(); -}); -it("retains existing matches for HTTP 304 responses", async () => { - mocks.isAxiosError.mockReturnValue(true); - mocks.http.mockRejectedValue({ response: { status: 304 } }); - await addTournamentMatches("2026test"); expect(mocks.tournament.update).not.toHaveBeenCalled(); - expect(mocks.teamMatchData.upsert).not.toHaveBeenCalled(); }); -it.each([new Error("offline"), { response: { status: 500 } }, {}])( - "does not write matches after failed TBA fetches (%j)", - async (error) => { - mocks.isAxiosError.mockReturnValue(true); - mocks.http.mockRejectedValue(error); - await addTournamentMatches("2026test"); - expect(mocks.teamMatchData.upsert).not.toHaveBeenCalled(); + +it.each([undefined, "", " "])( + "ignores invalid tournament keys (%s)", + async (key) => { + await addTournamentMatches(key as string); + + expect(mocks.tournament.findUnique).not.toHaveBeenCalled(); + expect(mocks.importJob.upsert).not.toHaveBeenCalled(); }, ); -it("ignores other seasons without contacting TBA", async () => { - await addTournamentMatches("2025test"); - expect(mocks.tournament.findUnique).not.toHaveBeenCalled(); - expect(mocks.fetch).not.toHaveBeenCalled(); -}); -it("handles undefined and unknown tournaments before fetching", async () => { - await addTournamentMatches(undefined as unknown as string); + +it("does not queue unknown tournaments", async () => { mocks.tournament.findUnique.mockResolvedValue(null); - await addTournamentMatches("2026test"); - expect(mocks.fetch).not.toHaveBeenCalled(); + + await addTournamentMatches("2026missing"); + + expect(mocks.importJob.upsert).not.toHaveBeenCalled(); }); -it("rejects malformed playoff team numbers before upserting", async () => { - mocks.fetch.mockResolvedValue({ json: async () => ({ playoff_type: 10 }) }); - matches = [ - { - ...match("2026test_sf1", "sf"), - alliances: { - red: { team_keys: ["invalid", "frc2", "frc3"] }, - blue: { team_keys: ["frc4", "frc5", "frc6"] }, - }, - }, - ]; - await addTournamentMatches("2026test"); - expect(mocks.teamMatchData.upsert).not.toHaveBeenCalled(); -}); -it("sorts playoff matches with missing actual and scheduled timestamps", async () => { - mocks.fetch.mockResolvedValue({ - json: async () => ({ playoff_type: 10, remap_teams: { frc1: "frc1B" } }), - }); - matches = [match("2026test_sf1m1", "sf"), match("2026test_sf2m1", "sf")].map( - (value) => ({ - ...value, - actual_time: null as unknown as number, - time: null as unknown as number, + +it("supports historical seasons without a hardcoded season gate", async () => { + await addTournamentMatches("2025test"); + + expect(mocks.importJob.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { kind_targetKey: { kind: "matches", targetKey: "2025test" } }, }), ); - await addTournamentMatches("2026test"); - expect(mocks.teamMatchData.upsert).toHaveBeenCalledTimes(12); +}); + +it("keeps stored-match reads available if enqueueing fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.importJob.upsert.mockRejectedValue(new Error("database unavailable")); + + await expect(addTournamentMatches("2026test")).resolves.toBeUndefined(); + + expect(mocks.teamMatchData.upsert).not.toHaveBeenCalled(); }); diff --git a/apps/server/test/report.integration.test.ts b/apps/server/test/report.integration.test.ts index 08db6fd2..395b66d7 100644 --- a/apps/server/test/report.integration.test.ts +++ b/apps/server/test/report.integration.test.ts @@ -87,6 +87,7 @@ const userId = randomUUID(); const leadUserId = randomUUID(); const otherUserId = randomUUID(); const otherTeamNumber = teamNumber - 1; +const robotTeamNumber = teamNumber - 2; const otherScouterUuid = randomUUID(); const report = (uuid: string) => ({ @@ -109,7 +110,7 @@ const report = (uuid: string) => ({ autoClimb: "NOT_ATTEMPTED", endgameClimb: "NOT_ATTEMPTED", scouterUuid, - teamNumber: 9999, + teamNumber: robotTeamNumber, events: [ [0, 2, 8], [10, 0, 2], @@ -187,12 +188,16 @@ beforeAll(async () => { await db.tournament.create({ data: { key: tournamentKey, name: "Synthetic tournament" }, }); + await db.team.create({ + data: { number: robotTeamNumber, name: "Synthetic report robot" }, + }); + await db.teamMatchData.create({ data: { key: matchKey, tournamentKey, matchNumber: 1, - teamNumber: 9999, + teamNumber: robotTeamNumber, matchType: "QUALIFICATION", }, }); @@ -202,6 +207,8 @@ afterAll(async () => { await db.team.deleteMany({ where: { number: teamNumber } }); await db.team.deleteMany({ where: { number: otherTeamNumber } }); await db.tournament.deleteMany({ where: { key: tournamentKey } }); + await db.team.deleteMany({ where: { number: robotTeamNumber } }); + await kv.del([`auth:team:${teamNumber}`, `auth:team:${otherTeamNumber}`]); await closeRedis(); await db.$disconnect(); @@ -257,7 +264,7 @@ describe.sequential("report upload against disposable services", () => { await db.cachedAnalysis.create({ data: { key: cacheKey, - teamDependencies: [9999], + teamDependencies: [robotTeamNumber], tournamentDependencies: [], }, }); diff --git a/apps/worker/.env.example b/apps/worker/.env.example new file mode 100644 index 00000000..7ce88949 --- /dev/null +++ b/apps/worker/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL=postgresql://lovat_test:lovat_test@127.0.0.1:5432/lovat_test +TBA_KEY= +WORKER_CONCURRENCY=2 +PORT=8080 diff --git a/apps/worker/.gitignore b/apps/worker/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/apps/worker/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile new file mode 100644 index 00000000..22b3abe2 --- /dev/null +++ b/apps/worker/Dockerfile @@ -0,0 +1,21 @@ +# Build from the repository root: docker build -f apps/worker/Dockerfile . +FROM oven/bun:1.3.14-debian AS bun + +FROM node:22.20.0-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun + +WORKDIR /app +COPY packages/db ./packages/db +RUN npm --prefix packages/db ci --include=dev && npm --prefix packages/db run build + +COPY apps/worker ./apps/worker +WORKDIR /app/apps/worker +# Build the local database dependency before Bun copies it into node_modules. +RUN bun install --frozen-lockfile --ignore-scripts && bun run typecheck + +ENV NODE_ENV=production +ENV PORT=8080 +USER node +EXPOSE 8080 +CMD ["bun", "index.ts"] diff --git a/apps/worker/Dockerfile.dockerignore b/apps/worker/Dockerfile.dockerignore new file mode 100644 index 00000000..b0ef9957 --- /dev/null +++ b/apps/worker/Dockerfile.dockerignore @@ -0,0 +1,15 @@ +** +!packages/ +!packages/db/ +!packages/db/** +!apps/ +!apps/worker/ +!apps/worker/** +**/node_modules +**/dist +**/coverage +**/src/generated +**/.env +**/.env.* +**/*.log +**/.DS_Store diff --git a/apps/worker/README.md b/apps/worker/README.md new file mode 100644 index 00000000..852243ac --- /dev/null +++ b/apps/worker/README.md @@ -0,0 +1,287 @@ +# worker + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run start +``` + +This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. + +Starting the worker queues and imports the current season into the configured +database. Set `DATABASE_URL` and `TBA_KEY` first. `WORKER_CONCURRENCY` defaults to +2 and supports 1–16 consumers. SIGINT/SIGTERM stops new claims, lets running +imports finish, and disconnects the database. + +## Database client + +Copy `.env.example` to `.env` and set `DATABASE_URL` for the intended database. +Import the shared client from `src/db.ts` (for example, `import { db } from +"../db"` in a job). Reuse it across jobs and call `await db.$disconnect()` during +worker shutdown. Importing it requires `DATABASE_URL` but does not connect until +the first query. `bun install` builds the shared database package through its +existing npm toolchain; this requires a supported Node.js version (22.20.0 or +24+). Schema and migrations remain owned by `packages/db`. + +## TBA helper + +Set `TBA_KEY` in `.env` and import the shared client in jobs: + +```ts +import { tba } from "../tba"; + +const status = await tba.getStatus(); +``` + +The shared client validates its key on import and makes requests only when a +method is called. Tests can use `createTbaClient()` with an injected fetcher. + +`createTbaClient()` reads `TBA_KEY`. It provides `getStatus()`, +`getTeamsPage(page)`, `getTournaments(year)`, `getMatchEvent(eventKey)`, +`getMatches(eventKey)`, and `get(resourceKey, zodSchema, options)` for other +endpoints. Resource keys are relative to `/api/v3/`, without a leading slash. +Requests time out after 15 seconds by default; callers can also supply a signal. + +Pass saved `etag` and `lastModified` values in the request options. A response +has `modified: true` and validated `data`, or `modified: false` for HTTP 304. +Persist returned validators with the imported records in one database +transaction. This helper does not write fetch state or retry failed requests; +the job runner owns retries and can inspect `TbaHttpError.status` and +`retryAfter`. + +For all teams, enumerate pages 0 through `getStatus()`'s `max_team_page`. +Do not stop at an empty page. If the status request returns 304, use a stored +page bound or fetch status unconditionally; 304 has no response data. + +Run `bun run test` and `bun run typecheck` to verify the helper. + +## Team import + +Call `importAllTeams()` from `src/jobs/teams.ts` to import the global team +catalog. Each page's team updates and cache headers commit together; retries +skip unchanged pages and resume pages that did not commit. This import writes +`Team` identities, not historical `TeamSeason` metadata. + +The team integration tests require an already-migrated disposable local +`lovat_test` database. Set `DATABASE_URL` to that database and `LOVAT_DB_TEST=1` +when running `bun run test`. They replace TBA responses with fixtures and do +not call the live API. Without the flag, database tests are skipped. + +## Tournament import + +```ts +import { importTournaments } from "./src/jobs/tournaments"; + +await importTournaments(2026, 2026); +``` + +The year range is inclusive and defaults to 2023 through the current UTC year. +The full TBA event endpoint supplies metadata, season/district relations, and +parent-event links. Each year and its fetch validators commit together. Missing +parents fail the transaction rather than leaving incomplete links. Existing +match-fetch ETags and scouting relations are preserved. This function does not +import event rosters or matches. Its database tests use the same opt-in setup +as the team tests. + +## Match import + +```ts +import { importMatches } from "./src/jobs/matches"; + +await importMatches("2026casj"); +``` + +Import the tournament first. This job fetches event metadata and full match +results, then saves matches, alliance scores and breakdowns, team slots, and +cache headers in one transaction. Missing teams receive placeholder names; +the global team import can fill them in later. + +Event metadata supplies remapped participant identities. Match validators are +reused only when the event ETag is unchanged. Existing qualification and double +elimination slot keys are reused to preserve scouting links. Changes to a team +with existing reports, or removal of a slot with reports, fail the transaction +and require reconciliation. Unscouted removed slots are deleted; matches missing +from a later response are retained. + +Practice matches are skipped because the current match enums do not support +them. The job does not populate event rosters; the scheduler runs roster imports +separately. Match +integration tests use the same disposable database setup as the other imports. + +Server requests queue a `matches` job instead of importing TBA data themselves. +They return the currently stored data; newly requested imports complete +asynchronously while the worker is running. Existing jobs retain their lease, +refresh time, and retry delay. The worker owns participant reconciliation and +match cache validators, so an API request cannot bypass scouting-link checks. + +Match display order follows a complete actual-time sequence when available, +otherwise a complete scheduled-time sequence. Before times are published, +traditional brackets alternate series by game number; double-elimination +brackets use numbered sets. Gap inference uses the same ordering, with stored +display order as a fallback when timing is incomplete. + +## Season, district and roster imports + +```ts +import { importTeamSeasons } from "./src/jobs/team-seasons"; +import { importDistricts, importDistrictTeams } from "./src/jobs/districts"; +import { importTournamentTeams } from "./src/jobs/tournament-teams"; + +await importTeamSeasons(2026); +await importDistricts(2026); +await importDistrictTeams("2026fim"); +await importTournamentTeams("2026casj"); // Tournament must exist first. +``` + +Season team pages populate `TeamSeason` names and locations without replacing +the global team's name or district membership. These are snapshots of TBA's +returned metadata; filtering by year does not establish that every metadata +field is historically accurate. District imports include districts with no +events and reconcile memberships through `TeamSeason.districtSeasonKey`. +Roster imports populate `TeamTournament`, apply participant remappings, and +remove stale roster memberships without touching matches or scouting reports. +Each response commits with its cache headers; failed transactions retain the +previous validators. Match and roster imports separately track the metadata +version applied to their records. + +The imports cover the current official-data tables, including alliance selections +and awards. Rankings, district points, Statbotics metrics, and practice matches +remain future work. + +## Scheduler + +`src/scheduler.ts` stores recurring work in `ImportJob`. The planner checks every +30 seconds and discovers current-season tournaments and districts. Tournament +and district catalog imports also queue child jobs for their imported year, +so manually queued historical years can be backfilled. + +Match refresh intervals use event-local dates and hours: + +| Event window | Match refresh | Roster refresh | +| ------------------------------ | ------------- | -------------- | +| Event dates, 07:00–21:00 local | 1 minute | 1 hour | +| Event dates, overnight | 15 minutes | 1 hour | +| Starts within 7 days | 30 minutes | 6 hours | +| Starts later, or dates unknown | 1 day | 1 day | +| Ended within 7 days | 6 hours | 1 day | +| Older event | 7 days | 7 days | + +Missing or invalid timezones fall back to UTC. Activity is inferred from dates, +not from a live event status feed. Due match jobs near the event's date window +receive priority over background imports. Global teams and season teams refresh +weekly, tournament catalogs every 6 hours, and districts/memberships daily. + +Claims use PostgreSQL `FOR UPDATE SKIP LOCKED`, a 90-second lease renewed every +30 seconds, and token checks on completion. Expired leases can be reclaimed; +execution is at least once, so imports must remain idempotent. Failures retain +the job and retry with exponential backoff from 1 minute to 1 hour, honoring +longer TBA `Retry-After` values. The planner preserves these retry delays. + +Queue a historical season or individual import while the worker is running: + +```bash +bun run enqueue tournaments 2023 +bun run enqueue districts 2023 +bun run enqueue team-seasons 2023 +bun run enqueue matches 2026casj +``` + +Enqueueing an existing job preserves its lease and scheduled time. Queue rows +hold pending/recurring work and the latest failure, rather than an execution +history. No new database migration is needed for this scheduler. + +## Tournament gaps + +`TournamentGap` stores inferred pauses between adjacent matches, with the +boundary match keys, start/end times, type, and timing source. Match imports +rebuild gaps in the same transaction as results and cache headers. HTTP 304 +responses also rebuild them from stored matches so an existing database can +be backfilled. A tournament row lock serializes gap recalculation with match +imports; stale gaps are replaced rather than accumulated. + +| Type | Inference | +| -------------------- | --------------------------------------------------------------------------------------------------- | +| `OVERNIGHT` | Different event-local dates, separated by at least 4 hours; represents EOD followed by a new day. | +| `DELAY` | An actual pause where the scheduled pair had no qualifying gap. | +| `PLAYOFF_TRANSITION` | At least 15 minutes from the last qualification match to the first playoff match. | +| `LUNCH` | A same-day pause of 30 minutes to 3 hours around the local lunch window. | +| `BREAK` | Another pause of at least 30 minutes, or insufficient local-time context for a more specific label. | + +Thresholds measure the interval between match starts. Comparable actual times +take precedence over scheduled times, followed by predicted times. The worker +does not mix actual and planned timestamps across a pair, bridge missing +qualification numbers or matches with unknown times, or invent an open-ended +EOD after the final match. Without a valid event timezone, lunch and overnight +classifications remain unknown breaks. + +All classifications are estimates, not official break announcements. The +inferred start uses result publication when it falls between the actual match +starts; otherwise it estimates 3 minutes after the preceding match starts. +The end is the following match's start. Stored matches retained after provider +removals also remain part of inference. Gaps do not suspend scheduler polling. + +Backfill gaps without fetching the provider: + +```ts +import { refreshTournamentGaps } from "./src/jobs/gaps"; + +await refreshTournamentGaps("2026casj"); +``` + +Or queue `bun run enqueue gaps 2026casj` for the worker. Explicit gap jobs repeat +daily; normal match jobs already recalculate gaps on every refresh. Apply the +shared database migration and rebuild the database package before running the +updated worker. + +## Railway deployment + +Build from the repository root with `docker build -f apps/worker/Dockerfile .`. +The image pins Node 22.20.0 and Bun 1.3.14, builds the shared Prisma package +before installing the worker with its frozen lockfile, checks TypeScript, and +runs as the non-root `node` user. The Docker build context excludes local +environment files, generated clients, dependency directories, and other apps. + +For the worker service, set Root Directory to `/` and Config File to +`/apps/worker/railway.json`. Configure these variables in the intended preview +environment: + +```text +DATABASE_URL=${{Postgres.DATABASE_PRIVATE_URL}} +TBA_KEY=${{api.TBA_KEY}} +WORKER_CONCURRENCY=2 +``` + +Service names in references must match that environment's services. Use the +preview database reference, not a production database URL. Railway supplies +`PORT`; local health checks default to 8080. The worker listens on `/health` and +returns 200 only after a successful scheduler pass within the last two minutes +and a working database query. Startup fails if the import/gap tables are absent. + +The worker does not run migrations. Deploy the shared schema through the +existing API/release migration owner before starting it. It runs continuously, +with sleeping disabled and one replica by default. SIGTERM stops new claims and +drains current jobs; Railway allows 60 seconds before termination. If a long +import is interrupted, its lease expires and another process can retry it. + +GitHub CI runs fixture-based database tests, typechecks, and a Docker build for +worker or shared database changes. Successful imports log their kind, target, +and next refresh time without credentials or scouting content. + +## Tournament presentation data + +`tournament-details` imports TBA alliance selections and awards, preserving pick +order, backups, and numeric participant remaps. It also imports raster avatars for +the roster's `TeamSeason` records. The scheduler refreshes details at the roster +cadence. Each event snapshot and its validators commit together; each team avatar +commits separately so a failed media request can resume without discarding event +data. A fresh empty response clears withdrawn data, while HTTP 304 preserves it. + +### Historical seasons + +Run `bun run backfill 1992 2025` with the target database and TBA credentials configured to queue archived season imports. With no arguments, it queues every season from 1992 through the current year. Existing jobs keep their leases and retry schedules. Tournament and district imports queue event and district details through the existing scheduler, so the backfill continues on the worker. diff --git a/apps/worker/bun.lock b/apps/worker/bun.lock new file mode 100644 index 00000000..6f91422e --- /dev/null +++ b/apps/worker/bun.lock @@ -0,0 +1,359 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "worker", + "dependencies": { + "@lovat/db": "file:../../packages/db", + "hono": "^4.13.13", + "zod": "4.6.5", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="], + + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" } }, "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg=="], + + "@lovat/db": ["@lovat/db@file:../../packages/db", { "dependencies": { "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "dotenv": "16.6.1", "pg": "8.16.3" }, "devDependencies": { "@types/node": "^22.20.2", "@types/pg": "^8.15.0", "prisma": "7.10.0", "typescript": "5.9.3" } }], + + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.10.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.10.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw=="], + + "@prisma/client": ["@prisma/client@7.10.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.10.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q=="], + + "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.10.0", "", {}, "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw=="], + + "@prisma/config": ["@prisma/config@7.10.0", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA=="], + + "@prisma/debug": ["@prisma/debug@7.10.0", "", {}, "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g=="], + + "@prisma/dev": ["@prisma/dev@0.24.17", "", { "dependencies": { "@electric-sql/pglite": "0.4.3", "@electric-sql/pglite-socket": "0.1.3", "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "@prisma/streams-local": "0.1.11", "find-my-way": "9.7.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.4.2", "zeptomatch": "2.1.0" } }, "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA=="], + + "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA=="], + + "@prisma/engines": ["@prisma/engines@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "@prisma/fetch-engine": "7.10.0", "@prisma/get-platform": "7.10.0" } }, "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ=="], + + "@prisma/engines-version": ["@prisma/engines-version@7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "", {}, "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", "@prisma/get-platform": "7.10.0" } }, "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g=="], + + "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], + + "@prisma/streams-local": ["@prisma/streams-local@0.1.11", "", { "dependencies": { "ajv": "^8.12.0", "better-result": "^2.7.0", "env-paths": "^3.0.0", "proper-lockfile": "^4.1.2" } }, "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg=="], + + "@prisma/studio-core": ["@prisma/studio-core@0.33.0", "", { "dependencies": { "@radix-ui/react-toggle": "1.1.10", "@visx/curve": "4.0.1-alpha.0", "@visx/event": "4.0.1-alpha.0", "@visx/grid": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/responsive": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "d3-array": "3.2.4", "d3-shape": "3.2.0", "elkjs": "0.11.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="], + + "@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.1", "", {}, "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="], + + "@types/d3-format": ["@types/d3-format@3.0.1", "", {}, "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.2", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="], + + "@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/lodash": ["@types/lodash@4.17.26", "", {}, "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA=="], + + "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], + + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@visx/curve": ["@visx/curve@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg=="], + + "@visx/event": ["@visx/event@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/point": "4.0.1-alpha.0" } }, "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g=="], + + "@visx/grid": ["@visx/grid@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/point": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg=="], + + "@visx/group": ["@visx/group@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w=="], + + "@visx/point": ["@visx/point@4.0.1-alpha.0", "", {}, "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg=="], + + "@visx/responsive": ["@visx/responsive@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA=="], + + "@visx/scale": ["@visx/scale@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A=="], + + "@visx/shape": ["@visx/shape@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/vendor": "4.0.0-alpha.0", "classnames": "^2.3.1", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA=="], + + "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-delaunay": ["d3-delaunay@6.0.2", "", { "dependencies": { "delaunator": "5" } }, "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@3.1.0", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], + + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + + "find-my-way": ["find-my-way@9.7.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + + "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "grammex": ["grammex@3.1.13", "", {}, "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg=="], + + "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + + "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru.min": ["lru.min@1.1.5", "", {}, "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA=="], + + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], + + "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], + + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prisma": ["prisma@7.10.0", "", { "dependencies": { "@prisma/config": "7.10.0", "@prisma/dev": "0.24.17", "@prisma/engines": "7.10.0", "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "rc9": ["rc9@3.1.0", "", { "dependencies": { "defu": "^6.1.7", "destr": "^2.0.5" } }, "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + + "safe-regex2": ["safe-regex2@5.1.2", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="], + + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="], + + "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + + "@types/pg/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], + + "bun-types/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + + "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], + + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@types/pg/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/apps/worker/index.ts b/apps/worker/index.ts new file mode 100644 index 00000000..da0a3a52 --- /dev/null +++ b/apps/worker/index.ts @@ -0,0 +1,45 @@ +import { db } from "./src/db"; +import { tba } from "./src/tba"; +import { runWorker } from "./src/worker"; +import { createHealthApp, healthPort } from "./src/server"; + +// Validate the provider configuration before starting any background work. +void tba; + +const shutdown = new AbortController(); +const stop = () => shutdown.abort(); +let lastReconciledAt = 0; + +const port = healthPort(); +const concurrency = Number(process.env.WORKER_CONCURRENCY ?? 2); +const app = createHealthApp({ + isReady: () => + !shutdown.signal.aborted && + lastReconciledAt > 0 && + Date.now() - lastReconciledAt < 120_000, + checkDatabase: async () => { + await db.$queryRaw`SELECT 1`; + }, +}); + +// Migrations are owned by the release/API service, never each worker replica. +await db.$queryRaw`SELECT 1 FROM "ImportJob", "FetchState", "TournamentGap" LIMIT 0`; + +const server = Bun.serve({ hostname: "0.0.0.0", port, fetch: app.fetch }); +console.info( + `Worker started with ${concurrency} consumers; health port ${port}`, +); + +process.once("SIGINT", stop); +process.once("SIGTERM", stop); + +try { + await runWorker(db, shutdown.signal, concurrency, () => { + lastReconciledAt = Date.now(); + }); +} finally { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + await server.stop(true); + await db.$disconnect(); +} diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 00000000..788c91df --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,25 @@ +{ + "name": "worker", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "start": "bun run index.ts", + "enqueue": "bun run src/enqueue.ts", + "backfill": "bun run src/backfill.ts", + "postinstall": "npm --prefix ../../packages/db ci --include=dev && npm --prefix ../../packages/db run build", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@lovat/db": "file:../../packages/db", + "hono": "^4.13.13", + "zod": "4.6.5" + } +} diff --git a/apps/worker/railway.json b/apps/worker/railway.json new file mode 100644 index 00000000..b623e016 --- /dev/null +++ b/apps/worker/railway.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "apps/worker/Dockerfile", + "watchPatterns": ["/apps/worker/**", "/packages/db/**"] + }, + "deploy": { + "runtime": "V2", + "startCommand": "bun index.ts", + "numReplicas": 1, + "sleepApplication": false, + "healthcheckPath": "/health", + "healthcheckTimeout": 120, + "drainingSeconds": 60, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "multiRegionConfig": { + "us-west2": { "numReplicas": 1 } + } + } +} diff --git a/apps/worker/src/backfill.ts b/apps/worker/src/backfill.ts new file mode 100644 index 00000000..3df1f636 --- /dev/null +++ b/apps/worker/src/backfill.ts @@ -0,0 +1,27 @@ +import { db } from "./db"; +import { enqueueImport } from "./scheduler"; +import { validateSeason } from "./jobs/shared"; + +const [first = "1992", last = String(new Date().getUTCFullYear())] = + process.argv.slice(2); + +try { + const firstYear = Number(first); + const lastYear = Number(last); + + validateSeason(firstYear); + validateSeason(lastYear); + + if (firstYear > lastYear) + throw new Error("Provide an ascending season range"); + + for (let year = lastYear; year >= firstYear; year--) { + for (const kind of ["tournaments", "districts", "team-seasons"] as const) { + await enqueueImport(db, kind, String(year)); + } + + console.log(`Season ${year}: imports queued`); + } +} finally { + await db.$disconnect(); +} diff --git a/apps/worker/src/db.ts b/apps/worker/src/db.ts new file mode 100644 index 00000000..843e8b91 --- /dev/null +++ b/apps/worker/src/db.ts @@ -0,0 +1,2 @@ +// Reuse the shared package's single client and connection pool. +export { db } from "@lovat/db"; diff --git a/apps/worker/src/enqueue.ts b/apps/worker/src/enqueue.ts new file mode 100644 index 00000000..df3a7d99 --- /dev/null +++ b/apps/worker/src/enqueue.ts @@ -0,0 +1,18 @@ +import { db } from "./db"; +import { enqueueImport, jobKinds, type JobKind } from "./scheduler"; + +const [kind, target] = process.argv.slice(2); + +try { + if (!kind || !target || !jobKinds.includes(kind as JobKind)) { + throw new Error(`Usage: bun run enqueue <${jobKinds.join("|")}> `); + } + + const job = await enqueueImport(db, kind as JobKind, target); + + console.log( + `${job.kind} ${job.targetKey}: scheduled for ${job.runAt.toISOString()}`, + ); +} finally { + await db.$disconnect(); +} diff --git a/apps/worker/src/gaps.ts b/apps/worker/src/gaps.ts new file mode 100644 index 00000000..0e3a4ed1 --- /dev/null +++ b/apps/worker/src/gaps.ts @@ -0,0 +1,177 @@ +import type { Match, Prisma } from "@lovat/db"; + +import { orderMatches } from "./match-order"; + +type MatchTiming = Pick< + Match, + | "key" + | "competitionLevel" + | "setNumber" + | "matchNumber" + | "scheduledTime" + | "actualTime" + | "predictedTime" + | "postResultTime" +> & + Partial>; + +const minute = 60_000; +const minGap = 30 * minute; +const estimatedMatchDuration = 3 * minute; +const levels = { + QUALIFICATION: 0, + EIGHTHFINAL: 1, + QUARTERFINAL: 2, + SEMIFINAL: 3, + FINAL: 4, +}; + +function localTime(date: Date, timezone: string | null) { + // Without a valid timezone, do not guess lunch or a local day boundary. + if (!timezone) return null; + + try { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + .formatToParts(date) + .map((part) => [part.type, part.value]), + ); + + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + minutes: Number(parts.hour) * 60 + Number(parts.minute), + }; + } catch { + return null; + } +} + +function pairTiming(after: MatchTiming, before: MatchTiming) { + if (after.actualTime && before.actualTime) { + return { + after: after.actualTime, + before: before.actualTime, + timingSource: "ACTUAL" as const, + }; + } + + if (after.scheduledTime && before.scheduledTime) { + return { + after: after.scheduledTime, + before: before.scheduledTime, + timingSource: "SCHEDULED" as const, + }; + } + + if (after.predictedTime && before.predictedTime) { + return { + after: after.predictedTime, + before: before.predictedTime, + timingSource: "PREDICTED" as const, + }; + } + + return null; +} + +export function inferTournamentGaps( + tournamentKey: string, + timezone: string | null, + matches: MatchTiming[], +) { + const ordered = orderMatches(matches, (match) => ({ + level: levels[match.competitionLevel], + set: match.setNumber, + number: match.matchNumber, + scheduled: match.scheduledTime?.getTime() ?? null, + actual: match.actualTime?.getTime() ?? null, + display: match.displayOrder, + })); + const gaps: Prisma.TournamentGapCreateManyInput[] = []; + + for (let index = 1; index < ordered.length; index++) { + const after = ordered[index - 1]!; + const before = ordered[index]!; + + if ( + after.competitionLevel === "QUALIFICATION" && + before.competitionLevel === "QUALIFICATION" && + before.matchNumber !== after.matchNumber + 1 + ) + continue; + + const timing = pairTiming(after, before); + + // Never bridge over a match with missing timing, or mix actual and planned clocks. + if (!timing) continue; + + const duration = timing.before.getTime() - timing.after.getTime(); + const playoffTransition = + after.competitionLevel === "QUALIFICATION" && + before.competitionLevel !== "QUALIFICATION"; + + if (duration < (playoffTransition ? 15 * minute : minGap)) continue; + + const from = localTime(timing.after, timezone); + const to = localTime(timing.before, timezone); + const scheduledDuration = + after.scheduledTime && before.scheduledTime + ? before.scheduledTime.getTime() - after.scheduledTime.getTime() + : null; + const unplanned = + timing.timingSource === "ACTUAL" && + scheduledDuration !== null && + scheduledDuration > 0 && + scheduledDuration < (playoffTransition ? 15 * minute : minGap); + + let type: Prisma.TournamentGapCreateManyInput["type"] = "BREAK"; + + if (from && to && from.date !== to.date && duration >= 4 * 60 * minute) { + type = "OVERNIGHT"; + } else if (unplanned) { + type = "DELAY"; + } else if (playoffTransition) { + type = "PLAYOFF_TRANSITION"; + } else if ( + from && + to && + from.date === to.date && + from.minutes >= 10 * 60 + 30 && + from.minutes <= 14 * 60 && + to.minutes >= 11 * 60 && + to.minutes <= 15 * 60 && + (from.minutes + to.minutes) / 2 >= 11 * 60 + 30 && + duration <= 3 * 60 * minute + ) { + type = "LUNCH"; + } + + // Result publication is only useful if it falls between the adjacent starts. + const resultTime = + timing.timingSource === "ACTUAL" ? after.postResultTime : null; + const startTime = + resultTime && resultTime > timing.after && resultTime < timing.before + ? resultTime + : new Date(timing.after.getTime() + estimatedMatchDuration); + + gaps.push({ + tournamentKey, + afterMatchKey: after.key, + beforeMatchKey: before.key, + type, + timingSource: timing.timingSource, + startTime, + endTime: timing.before, + }); + } + + return gaps; +} diff --git a/apps/worker/src/jobs/avatar.ts b/apps/worker/src/jobs/avatar.ts new file mode 100644 index 00000000..3bca4194 --- /dev/null +++ b/apps/worker/src/jobs/avatar.ts @@ -0,0 +1,26 @@ +// TBA media contains base64 raster data. Detect its format instead of assuming PNG. +export function avatarDataUri(value: string | undefined) { + if (!value) return null; + + const base64 = value + .replace(/^data:image\/[a-z]+;base64,/, "") + .replace(/\s/g, ""); + + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) return null; + + const bytes = Buffer.from(base64, "base64"); + const signature = bytes.subarray(0, 12); + const hex = signature.toString("hex"); + const mime = hex.startsWith("89504e470d0a1a0a") + ? "image/png" + : hex.startsWith("ffd8ff") + ? "image/jpeg" + : signature.toString("ascii").startsWith("GIF8") + ? "image/gif" + : signature.toString("ascii").startsWith("RIFF") && + signature.toString("ascii", 8, 12) === "WEBP" + ? "image/webp" + : null; + + return mime ? `data:${mime};base64,${bytes.toString("base64")}` : null; +} diff --git a/apps/worker/src/jobs/districts.ts b/apps/worker/src/jobs/districts.ts new file mode 100644 index 00000000..72a22393 --- /dev/null +++ b/apps/worker/src/jobs/districts.ts @@ -0,0 +1,103 @@ +import type { PrismaClient } from "@lovat/db"; +import type { createTbaClient } from "../providers/tba"; +import { + saveHeaders, + savedHeaders, + saveSeasonTeam, + validateSeason, + validateTarget, +} from "./shared"; + +type Tba = ReturnType; + +export async function importDistricts( + year: number, + dependencies?: { db: PrismaClient; tba: Pick }, +) { + validateSeason(year); + + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + const resourceKey = `districts/${year}`; + const saved = await savedHeaders(db, resourceKey); + const fetched = await tba.getDistricts(year, saved ?? {}); + + if (!fetched.modified) return; + + await db.$transaction(async (tx) => { + await tx.season.upsert({ where: { year }, update: {}, create: { year } }); + + for (const district of fetched.data) { + if (district.year !== year) + throw new Error(`Inconsistent district season: ${district.key}`); + + const data = { + seasonYear: year, + name: district.display_name, + abbreviation: district.abbreviation, + }; + + await tx.districtSeason.upsert({ + where: { key: district.key }, + update: data, + create: { key: district.key, ...data }, + }); + } + + await saveHeaders(tx, resourceKey, fetched); + }); +} + +export async function importDistrictTeams( + districtKey: string, + dependencies?: { db: PrismaClient; tba: Pick }, +) { + validateTarget(districtKey); + + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + const district = await db.districtSeason.findUniqueOrThrow({ + where: { key: districtKey }, + }); + const resourceKey = `district/${districtKey}/teams/simple`; + const saved = await savedHeaders(db, resourceKey); + const fetched = await tba.getDistrictTeams(districtKey, saved ?? {}); + + if (!fetched.modified) return; + + await db.$transaction( + async (tx) => { + const numbers = fetched.data.map((team) => team.team_number); + + await tx.teamSeason.updateMany({ + where: { + seasonYear: district.seasonYear, + districtSeasonKey: districtKey, + teamNumber: { notIn: numbers }, + }, + data: { districtSeasonKey: null }, + }); + + for (const team of fetched.data) { + await saveSeasonTeam(tx, team, district.seasonYear); + + await tx.teamSeason.update({ + where: { + teamNumber_seasonYear: { + teamNumber: team.team_number, + seasonYear: district.seasonYear, + }, + }, + data: { districtSeasonKey: districtKey }, + }); + } + + await saveHeaders(tx, resourceKey, fetched); + }, + { timeout: 60_000 }, + ); +} diff --git a/apps/worker/src/jobs/gaps.ts b/apps/worker/src/jobs/gaps.ts new file mode 100644 index 00000000..2cd1416c --- /dev/null +++ b/apps/worker/src/jobs/gaps.ts @@ -0,0 +1,45 @@ +import type { Prisma, PrismaClient } from "@lovat/db"; +import { inferTournamentGaps } from "../gaps"; +import { validateTarget } from "./shared"; + +export async function lockTournament( + tx: Prisma.TransactionClient, + eventKey: string, +) { + await tx.$queryRaw`SELECT "key" FROM "Tournament" WHERE "key" = ${eventKey} FOR UPDATE`; +} + +export async function saveTournamentGaps( + tx: Prisma.TransactionClient, + eventKey: string, +) { + const event = await tx.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + select: { timezone: true }, + }); + const matches = await tx.match.findMany({ + where: { tournamentKey: eventKey }, + }); + const gaps = inferTournamentGaps(eventKey, event.timezone, matches); + + await tx.tournamentGap.deleteMany({ where: { tournamentKey: eventKey } }); + + if (gaps.length) await tx.tournamentGap.createMany({ data: gaps }); +} + +export async function refreshTournamentGaps( + eventKey: string, + dependencies?: { db: PrismaClient }, +) { + validateTarget(eventKey); + + const { db } = dependencies ?? { db: (await import("../db")).db }; + + await db.$transaction( + async (tx) => { + await lockTournament(tx, eventKey); + await saveTournamentGaps(tx, eventKey); + }, + { timeout: 60_000 }, + ); +} diff --git a/apps/worker/src/jobs/matches.ts b/apps/worker/src/jobs/matches.ts new file mode 100644 index 00000000..12cb11b8 --- /dev/null +++ b/apps/worker/src/jobs/matches.ts @@ -0,0 +1,349 @@ +import type { PrismaClient } from "@lovat/db"; +import type { createTbaClient, TbaMatch } from "../providers/tba"; +import { + lockTournament, + refreshTournamentGaps, + saveTournamentGaps, +} from "./gaps"; + +import { orderMatches } from "../match-order"; + +type Dependencies = { + db: PrismaClient; + tba: Pick, "getMatchEvent" | "getMatches">; +}; + +const levels = { + qm: "QUALIFICATION", + ef: "EIGHTHFINAL", + qf: "QUARTERFINAL", + sf: "SEMIFINAL", + f: "FINAL", +} as const; + +const levelOrder = { pm: -1, qm: 0, ef: 1, qf: 2, sf: 3, f: 4 }; + +const timestamp = (seconds: number | null) => + seconds === null ? null : new Date(seconds * 1_000); + +function legacyPlayoffNumber(match: TbaMatch, playoffType: number | null) { + const semifinalCount = + playoffType === 10 ? 13 : playoffType === 11 ? 5 : null; + + if (semifinalCount === null) return null; + + if ( + match.comp_level === "sf" && + match.match_number === 1 && + match.set_number <= semifinalCount + ) { + return match.set_number; + } + + if (match.comp_level === "f" && match.set_number === 1) { + return semifinalCount + match.match_number; + } + + return null; +} + +export const importMatches = async ( + eventKey: string, + dependencies?: Dependencies, +) => { + if (!eventKey.trim()) throw new Error("Tournament key is required"); + + const { Prisma } = await import("@lovat/db"); + + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + + await db.tournament.findUniqueOrThrow({ where: { key: eventKey } }); + + const matchIdentity = { + provider: "tba", + resourceKey: `event/${eventKey}/matches`, + }; + const eventIdentity = { + provider: "tba", + resourceKey: `event/${eventKey}/matches?dependency=event`, + }; + + const [savedMatches, savedEvent, event] = await Promise.all([ + db.fetchState.findUnique({ + where: { provider_resourceKey: matchIdentity }, + }), + db.fetchState.findUnique({ + where: { provider_resourceKey: eventIdentity }, + }), + tba.getMatchEvent(eventKey), + ]); + + if (!event.modified || event.data.key !== eventKey) { + throw new Error("Expected fresh metadata for the requested tournament"); + } + + // Remappings affect participant identity even when the match payload is unchanged. + const metadataUnchanged = Boolean( + event.etag && savedEvent?.etag === event.etag, + ); + + const fetched = await tba.getMatches( + eventKey, + metadataUnchanged + ? { + etag: savedMatches?.etag, + lastModified: savedMatches?.lastModified, + } + : {}, + ); + + if (!fetched.modified) { + await refreshTournamentGaps(eventKey, { db }); + return; + } + + const matches = orderMatches( + fetched.data.filter((match) => match.comp_level !== "pm"), + (match) => ({ + level: levelOrder[match.comp_level], + set: match.set_number, + number: match.match_number, + scheduled: match.time, + actual: match.actual_time, + }), + event.data.playoff_type === 10 || event.data.playoff_type === 11, + ); + + const aliases = new Map(); + + for (const [numericKey, participantKey] of Object.entries( + event.data.remap_teams ?? {}, + )) { + if (aliases.has(participantKey)) + throw new Error(`Ambiguous participant mapping: ${participantKey}`); + + aliases.set(participantKey, numericKey); + } + + await db.$transaction( + async (tx) => { + await lockTournament(tx, eventKey); + + let eliminationOrder = 0; + + for (const [index, match] of matches.entries()) { + if ( + match.event_key !== eventKey || + !match.key.startsWith(`${eventKey}_`) + ) { + throw new Error(`Match does not belong to tournament: ${match.key}`); + } + + if (match.comp_level === "pm") continue; + + const qualification = match.comp_level === "qm"; + if (!qualification) eliminationOrder++; + + const playoffNumber = legacyPlayoffNumber( + match, + event.data.playoff_type, + ); + const displayNumber = qualification + ? match.match_number + : (playoffNumber ?? eliminationOrder); + const completed = [ + match.alliances.red.score, + match.alliances.blue.score, + ].every((score) => score !== null && score >= 0); + + const data = { + tournamentKey: eventKey, + competitionLevel: levels[match.comp_level], + setNumber: match.set_number, + matchNumber: match.match_number, + displayOrder: index + 1, + scheduledTime: timestamp(match.time), + predictedTime: timestamp(match.predicted_time), + actualTime: timestamp(match.actual_time), + postResultTime: timestamp(match.post_result_time), + status: completed ? ("COMPLETED" as const) : ("SCHEDULED" as const), + winningAlliance: + completed && match.winning_alliance + ? match.winning_alliance === "red" + ? ("RED" as const) + : ("BLUE" as const) + : null, + }; + + await tx.match.upsert({ + where: { key: match.key }, + update: data, + create: { key: match.key, ...data }, + }); + + for (const [color, alliance] of Object.entries(match.alliances) as [ + "red" | "blue", + TbaMatch["alliances"]["red"], + ][]) { + const allianceColor = + color === "red" ? ("RED" as const) : ("BLUE" as const); + + const allianceData = { + score: + alliance.score === null || alliance.score < 0 + ? null + : alliance.score, + scoreBreakdown: match.score_breakdown?.[color] ?? Prisma.DbNull, + }; + + await tx.matchAlliance.upsert({ + where: { + matchKey_color: { matchKey: match.key, color: allianceColor }, + }, + update: allianceData, + create: { + matchKey: match.key, + color: allianceColor, + ...allianceData, + }, + }); + + const removedSlots = await tx.teamMatchData.findMany({ + where: { + matchKey: match.key, + alliance: allianceColor, + station: { gt: alliance.team_keys.length }, + }, + include: { _count: { select: { scoutReports: true } } }, + }); + + for (const removed of removedSlots) { + if (removed._count.scoutReports > 0) { + throw new Error( + `Removed participant requires scouting reconciliation: ${removed.key}`, + ); + } + + await tx.teamMatchData.delete({ where: { key: removed.key } }); + } + + for (const [slot, participantKey] of alliance.team_keys.entries()) { + const numericKey = aliases.get(participantKey) ?? participantKey; + + if (!/^frc\d+$/.test(numericKey)) { + throw new Error(`Missing numeric mapping for ${participantKey}`); + } + + const teamNumber = Number(numericKey.slice(3)); + + if ( + !Number.isSafeInteger(teamNumber) || + teamNumber <= 0 || + teamNumber > 2_147_483_647 + ) { + throw new Error(`Invalid team number: ${numericKey}`); + } + + const station = slot + 1; + const slotIndex = (color === "red" ? 0 : 3) + slot; + const legacyKey = qualification + ? `${eventKey}_qm${match.match_number}_${slotIndex}` + : playoffNumber === null + ? null + : `${eventKey}_em${playoffNumber}_${slotIndex}`; + + const linked = await tx.teamMatchData.findUnique({ + where: { + matchKey_alliance_station: { + matchKey: match.key, + alliance: allianceColor, + station, + }, + }, + include: { _count: { select: { scoutReports: true } } }, + }); + + const existing = + linked ?? + (await tx.teamMatchData.findUnique({ + where: { key: legacyKey ?? `${match.key}_${slotIndex}` }, + include: { _count: { select: { scoutReports: true } } }, + })); + + if ( + existing && + ((existing.matchKey && existing.matchKey !== match.key) || + (existing.teamNumber !== teamNumber && + existing._count.scoutReports > 0)) + ) { + throw new Error( + `Participant change requires scouting reconciliation: ${existing.key}`, + ); + } + + await tx.team.upsert({ + where: { number: teamNumber }, + update: {}, + create: { number: teamNumber, name: `Team ${teamNumber}` }, + }); + + const slotData = { + tournamentKey: eventKey, + matchNumber: existing?.matchNumber ?? displayNumber, + matchType: qualification + ? ("QUALIFICATION" as const) + : ("ELIMINATION" as const), + teamNumber, + matchKey: match.key, + alliance: allianceColor, + station, + externalParticipantKey: participantKey, + disqualified: alliance.dq_team_keys.includes(participantKey), + surrogate: alliance.surrogate_team_keys.includes(participantKey), + }; + + const key = + existing?.key ?? legacyKey ?? `${match.key}_${slotIndex}`; + + await tx.teamMatchData.upsert({ + where: { key }, + update: slotData, + create: { key, ...slotData }, + }); + } + } + } + + await saveTournamentGaps(tx, eventKey); + + for (const [identity, response] of [ + [matchIdentity, fetched], + [eventIdentity, event], + ] as const) { + const headers = { + etag: response.etag, + lastModified: response.lastModified, + }; + + await tx.fetchState.upsert({ + where: { provider_resourceKey: identity }, + update: headers, + create: { ...identity, ...headers }, + }); + } + + await tx.tournament.update({ + where: { key: eventKey }, + data: { + playoffType: event.data.playoff_type, + officialDataUpdatedAt: new Date(), + officialDataRevision: { increment: 1 }, + }, + }); + }, + { timeout: 60_000 }, + ); +}; diff --git a/apps/worker/src/jobs/shared.ts b/apps/worker/src/jobs/shared.ts new file mode 100644 index 00000000..038afc9a --- /dev/null +++ b/apps/worker/src/jobs/shared.ts @@ -0,0 +1,77 @@ +import type { Prisma, PrismaClient } from "@lovat/db"; +import type { TbaResponse, TbaTeam } from "../providers/tba"; + +export function validateSeason(year: number) { + if (!Number.isSafeInteger(year) || year < 1992 || year > 9999) { + throw new Error("Season year must be an integer between 1992 and 9999"); + } +} + +export function validateTarget(key: string) { + if (!key.trim()) throw new Error("Import target key is required"); +} + +export function cacheIdentity(resourceKey: string) { + return { provider: "tba", resourceKey }; +} + +export async function savedHeaders(db: PrismaClient, resourceKey: string) { + return db.fetchState.findUnique({ + where: { provider_resourceKey: cacheIdentity(resourceKey) }, + }); +} + +export async function saveHeaders( + tx: Prisma.TransactionClient, + resourceKey: string, + response: TbaResponse, +) { + const identity = cacheIdentity(resourceKey); + const headers = { etag: response.etag, lastModified: response.lastModified }; + + await tx.fetchState.upsert({ + where: { provider_resourceKey: identity }, + update: headers, + create: { ...identity, ...headers }, + }); +} + +export async function saveSeasonTeam( + tx: Prisma.TransactionClient, + team: TbaTeam, + year: number, +) { + if ( + team.key !== `frc${team.team_number}` || + !Number.isSafeInteger(team.team_number) || + team.team_number <= 0 || + team.team_number > 2_147_483_647 + ) { + throw new Error(`Inconsistent team identity: ${team.key}`); + } + + const name = + team.nickname?.trim() || team.name.trim() || `Team ${team.team_number}`; + + // Historical imports must not replace the global team's current name. + await tx.team.upsert({ + where: { number: team.team_number }, + update: {}, + create: { number: team.team_number, name }, + }); + + const data = { + name, + city: team.city, + stateProvince: team.state_prov, + country: team.country, + }; + + await tx.teamSeason.upsert({ + where: { + teamNumber_seasonYear: { teamNumber: team.team_number, seasonYear: year }, + }, + update: data, + create: { teamNumber: team.team_number, seasonYear: year, ...data }, + }); +} diff --git a/apps/worker/src/jobs/team-seasons.ts b/apps/worker/src/jobs/team-seasons.ts new file mode 100644 index 00000000..a19814ce --- /dev/null +++ b/apps/worker/src/jobs/team-seasons.ts @@ -0,0 +1,54 @@ +import type { PrismaClient } from "@lovat/db"; +import type { createTbaClient } from "../providers/tba"; +import { + saveHeaders, + savedHeaders, + saveSeasonTeam, + validateSeason, +} from "./shared"; + +type Dependencies = { + db: PrismaClient; + tba: Pick< + ReturnType, + "getStatus" | "getSeasonTeamsPage" + >; +}; + +export async function importTeamSeasons( + year: number, + dependencies?: Dependencies, +) { + validateSeason(year); + + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + const status = await tba.getStatus(); + + if (!status.modified) throw new Error("Expected fresh TBA status"); + + for (let page = 0; page <= status.data.max_team_page; page++) { + const resourceKey = `teams/${year}/${page}/simple`; + const saved = await savedHeaders(db, resourceKey); + const fetched = await tba.getSeasonTeamsPage(year, page, saved ?? {}); + + if (!fetched.modified) continue; + + await db.$transaction( + async (tx) => { + await tx.season.upsert({ + where: { year }, + update: {}, + create: { year }, + }); + + for (const team of fetched.data) await saveSeasonTeam(tx, team, year); + + await saveHeaders(tx, resourceKey, fetched); + }, + { timeout: 60_000 }, + ); + } +} diff --git a/apps/worker/src/jobs/teams.ts b/apps/worker/src/jobs/teams.ts new file mode 100644 index 00000000..06342df0 --- /dev/null +++ b/apps/worker/src/jobs/teams.ts @@ -0,0 +1,62 @@ +import type { PrismaClient } from "@lovat/db"; +import type { createTbaClient } from "../providers/tba"; + +type Dependencies = { + db: PrismaClient; + tba: Pick, "getStatus" | "getTeamsPage">; +}; + +export const importAllTeams = async (dependencies?: Dependencies) => { + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + + // Fetch status unconditionally: FetchState stores headers, not its page bound. + const status = await tba.getStatus(); + + if (!status.modified) throw new Error("Expected fresh TBA status"); + + const maxTeamPage = status.data.max_team_page; + + for (let page = 0; page <= maxTeamPage; page++) { + const identity = { provider: "tba", resourceKey: `teams/${page}/simple` }; + + const saved = await db.fetchState.findUnique({ + where: { provider_resourceKey: identity }, + }); + + const fetched = await tba.getTeamsPage(page, { + etag: saved?.etag, + lastModified: saved?.lastModified, + }); + + if (!fetched.modified) continue; + + await db.$transaction(async (tx) => { + for (const team of fetched.data) { + const name = + team.nickname?.trim() || + team.name.trim() || + `Team ${team.team_number}`; + + await tx.team.upsert({ + where: { number: team.team_number }, + update: { name }, + create: { number: team.team_number, name }, + }); + } + + const headers = { + etag: fetched.etag, + lastModified: fetched.lastModified, + }; + + await tx.fetchState.upsert({ + where: { provider_resourceKey: identity }, + update: headers, + create: { ...identity, ...headers }, + }); + }); + } +}; diff --git a/apps/worker/src/jobs/tournament-details.ts b/apps/worker/src/jobs/tournament-details.ts new file mode 100644 index 00000000..a8788e5b --- /dev/null +++ b/apps/worker/src/jobs/tournament-details.ts @@ -0,0 +1,154 @@ +import { avatarDataUri } from "./avatar"; +import type { PrismaClient } from "@lovat/db"; +import type { createTbaClient } from "../providers/tba"; +import { saveHeaders, savedHeaders, validateTarget } from "./shared"; + +type Dependencies = { + db: PrismaClient; + tba: Pick< + ReturnType, + "getSelections" | "getAwards" | "getTeamMedia" | "getMatchEvent" + >; +}; + +export async function importTournamentDetails( + eventKey: string, + dependencies?: Dependencies, +) { + validateTarget(eventKey); + + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + const tournament = await db.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + }); + + if (tournament.seasonYear === null) + throw new Error("Import tournament metadata first"); + + const event = await tba.getMatchEvent(eventKey); + + if (!event.modified || event.data.key !== eventKey) + throw new Error("Expected tournament metadata"); + + const aliases = new Map(); + + for (const [numeric, alias] of Object.entries(event.data.remap_teams ?? {})) { + if (aliases.has(alias)) throw new Error("Ambiguous participant mapping"); + + aliases.set(alias, numeric); + } + + function number(key: string) { + const numeric = aliases.get(key) ?? key; + + if (!/^frc\d+$/.test(numeric)) + throw new Error(`Missing numeric mapping for ${key}`); + + const value = Number(numeric.slice(3)); + + if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) + throw new Error("Invalid team number"); + + return value; + } + + const selectionsKey = `event/${eventKey}/alliances`; + const awardsKey = `event/${eventKey}/awards`; + const metadataKey = `event/${eventKey}/details?dependency=event`; + const [savedSelections, savedAwards, savedMetadata] = await Promise.all([ + savedHeaders(db, selectionsKey), + savedHeaders(db, awardsKey), + savedHeaders(db, metadataKey), + ]); + const sameMapping = event.etag && event.etag === savedMetadata?.etag; + const [selections, awards] = await Promise.all([ + tba.getSelections(eventKey, sameMapping ? (savedSelections ?? {}) : {}), + tba.getAwards(eventKey, sameMapping ? (savedAwards ?? {}) : {}), + ]); + + await db.$transaction(async (tx) => { + if (selections.modified) { + const allianceSelections = selections.data.map((alliance) => ({ + teams: alliance.picks.map(number), + backup: alliance.backup + ? { in: number(alliance.backup.in), out: number(alliance.backup.out) } + : null, + })); + + await tx.tournament.update({ + where: { key: eventKey }, + data: { allianceSelections }, + }); + await saveHeaders(tx, selectionsKey, selections); + } + + if (awards.modified) { + if (awards.data.some((award) => award.event_key !== eventKey)) + throw new Error("Award event mismatch"); + + const records = awards.data.map((award) => ({ + type: award.award_type, + name: award.name, + recipients: award.recipient_list.map((recipient) => ({ + teamNumber: recipient.team_key ? number(recipient.team_key) : null, + name: recipient.awardee, + })), + })); + + await tx.tournament.update({ + where: { key: eventKey }, + data: { awards: records }, + }); + await saveHeaders(tx, awardsKey, awards); + } + + await saveHeaders(tx, metadataKey, event); + }); + + const teams = await db.teamTournament.findMany({ + where: { tournamentKey: eventKey }, + }); + + // Each avatar commits separately so a media failure does not roll back event details. + for (const { teamNumber } of teams) { + const season = await db.teamSeason.findUnique({ + where: { + teamNumber_seasonYear: { + teamNumber, + seasonYear: tournament.seasonYear, + }, + }, + }); + + if (!season) continue; // The roster import will create missing season records. + + const resourceKey = `team/frc${teamNumber}/media/${tournament.seasonYear}`; + const media = await tba.getTeamMedia( + teamNumber, + tournament.seasonYear, + (await savedHeaders(db, resourceKey)) ?? {}, + ); + + if (!media.modified) continue; + + const avatars = media.data.filter((item) => item.type === "avatar"); + const base64 = (avatars.find((item) => item.preferred) ?? avatars[0]) + ?.details?.base64Image; + + await db.$transaction(async (tx) => { + await tx.teamSeason.update({ + where: { + teamNumber_seasonYear: { + teamNumber, + seasonYear: tournament.seasonYear!, + }, + }, + data: { avatar: avatarDataUri(base64) }, + }); + await saveHeaders(tx, resourceKey, media); + }); + } +} diff --git a/apps/worker/src/jobs/tournament-teams.ts b/apps/worker/src/jobs/tournament-teams.ts new file mode 100644 index 00000000..3ac6b133 --- /dev/null +++ b/apps/worker/src/jobs/tournament-teams.ts @@ -0,0 +1,103 @@ +import type { PrismaClient } from "@lovat/db"; +import type { createTbaClient } from "../providers/tba"; +import { + saveHeaders, + savedHeaders, + saveSeasonTeam, + validateTarget, +} from "./shared"; + +type Dependencies = { + db: PrismaClient; + tba: Pick< + ReturnType, + "getTournamentTeams" | "getMatchEvent" + >; +}; + +export async function importTournamentTeams( + eventKey: string, + dependencies?: Dependencies, +) { + validateTarget(eventKey); + + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + const tournament = await db.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + }); + + if (tournament.seasonYear === null) + throw new Error("Import tournament season metadata first"); + + const resourceKey = `event/${eventKey}/teams/simple`; + // Each consumer tracks the metadata version applied to its own records. + const metadataKey = `${resourceKey}?dependency=event`; + + const [saved, savedMetadata, event] = await Promise.all([ + savedHeaders(db, resourceKey), + savedHeaders(db, metadataKey), + tba.getMatchEvent(eventKey), + ]); + + if (!event.modified || event.data.key !== eventKey) + throw new Error("Expected fresh tournament metadata"); + + const fetched = await tba.getTournamentTeams( + eventKey, + event.etag && event.etag === savedMetadata?.etag ? (saved ?? {}) : {}, + ); + + if (!fetched.modified) return; + + const aliases = new Map(); + + for (const [numeric, alias] of Object.entries(event.data.remap_teams ?? {})) { + if (aliases.has(alias)) + throw new Error(`Ambiguous participant mapping: ${alias}`); + + aliases.set(alias, numeric); + } + + await db.$transaction( + async (tx) => { + const numbers: number[] = []; + + for (const team of fetched.data) { + const key = aliases.get(team.key) ?? team.key; + + if (!/^frc\d+$/.test(key)) + throw new Error(`Missing numeric mapping for ${team.key}`); + + const teamNumber = Number(key.slice(3)); + + await saveSeasonTeam( + tx, + { ...team, key, team_number: teamNumber }, + tournament.seasonYear!, + ); + + await tx.teamTournament.upsert({ + where: { + teamNumber_tournamentKey: { teamNumber, tournamentKey: eventKey }, + }, + update: {}, + create: { teamNumber, tournamentKey: eventKey }, + }); + + numbers.push(teamNumber); + } + + // Roster corrections do not touch match slots or scouting reports. + await tx.teamTournament.deleteMany({ + where: { tournamentKey: eventKey, teamNumber: { notIn: numbers } }, + }); + + await saveHeaders(tx, resourceKey, fetched); + await saveHeaders(tx, metadataKey, event); + }, + { timeout: 60_000 }, + ); +} diff --git a/apps/worker/src/jobs/tournaments.ts b/apps/worker/src/jobs/tournaments.ts new file mode 100644 index 00000000..2e3b7487 --- /dev/null +++ b/apps/worker/src/jobs/tournaments.ts @@ -0,0 +1,132 @@ +import type { PrismaClient } from "@lovat/db"; +import type { createTbaClient } from "../providers/tba"; + +type Dependencies = { + db: PrismaClient; + tba: Pick, "getTournaments">; +}; + +export const importTournaments = async ( + initialYear = 2023, + finalYear = new Date().getUTCFullYear(), + dependencies?: Dependencies, +) => { + if ( + !Number.isSafeInteger(initialYear) || + !Number.isSafeInteger(finalYear) || + initialYear < 1992 || + finalYear < initialYear + ) { + throw new Error( + "Provide an ascending tournament year range starting in 1992 or later", + ); + } + + const { db, tba } = dependencies ?? { + db: (await import("../db")).db, + tba: (await import("../tba")).tba, + }; + + for (let year = initialYear; year <= finalYear; year++) { + const identity = { + provider: "tba", + resourceKey: `events/${year}?schema=weeks-v1`, + }; + + const saved = await db.fetchState.findUnique({ + where: { provider_resourceKey: identity }, + }); + + const fetched = await tba.getTournaments(year, { + etag: saved?.etag, + lastModified: saved?.lastModified, + }); + + if (!fetched.modified) continue; + + for (const tournament of fetched.data) { + if ( + tournament.year !== year || + (tournament.district && tournament.district.year !== year) + ) { + throw new Error( + `TBA returned inconsistent season data for ${tournament.key}`, + ); + } + + if (tournament.parent_event_key === tournament.key) { + throw new Error( + `TBA tournament cannot be its own parent: ${tournament.key}`, + ); + } + } + + await db.$transaction( + async (tx) => { + await tx.season.upsert({ + where: { year }, + update: {}, + create: { year }, + }); + + for (const tournament of fetched.data) { + const district = tournament.district; + + if (district) { + const data = { + seasonYear: district.year, + abbreviation: district.abbreviation, + name: district.display_name, + }; + + await tx.districtSeason.upsert({ + where: { key: district.key }, + update: data, + create: { key: district.key, ...data }, + }); + } + + const data = { + name: tournament.name, + location: tournament.city, + date: tournament.start_date, + seasonYear: year, + startDate: new Date(`${tournament.start_date}T00:00:00.000Z`), + endDate: new Date(`${tournament.end_date}T00:00:00.000Z`), + timezone: tournament.timezone, + eventType: tournament.event_type, + week: tournament.week ?? null, + playoffType: tournament.playoff_type, + districtSeasonKey: district?.key ?? null, + }; + + await tx.tournament.upsert({ + where: { key: tournament.key }, + update: data, + create: { key: tournament.key, ...data }, + }); + } + + // All tournament parents must exist before linking divisions. + for (const tournament of fetched.data) { + await tx.tournament.update({ + where: { key: tournament.key }, + data: { parentTournamentKey: tournament.parent_event_key }, + }); + } + + const headers = { + etag: fetched.etag, + lastModified: fetched.lastModified, + }; + + await tx.fetchState.upsert({ + where: { provider_resourceKey: identity }, + update: headers, + create: { ...identity, ...headers }, + }); + }, + { timeout: 60_000 }, + ); + } +}; diff --git a/apps/worker/src/match-order.ts b/apps/worker/src/match-order.ts new file mode 100644 index 00000000..0af575e5 --- /dev/null +++ b/apps/worker/src/match-order.ts @@ -0,0 +1,46 @@ +type Sequence = { + level: number; + set: number; + number: number; + scheduled: number | null; + actual: number | null; + display?: number | null; +}; + +export function orderMatches( + matches: T[], + sequence: (match: T) => Sequence, + doubleElimination = false, +) { + const entries = matches.map((match) => ({ match, order: sequence(match) })); + + // Choose one clock for the whole schedule; mixing clocks can skip matches. + const clock = entries.every(({ order }) => order.actual !== null) + ? "actual" + : entries.every(({ order }) => order.scheduled !== null) + ? "scheduled" + : null; + const hasDisplay = entries.every(({ order }) => order.display != null); + + return entries + .sort(({ order: a }, { order: b }) => { + if (clock) { + const difference = a[clock]! - b[clock]!; + + if (difference) return difference; + } else if (hasDisplay) { + const difference = a.display! - b.display!; + + if (difference) return difference; + } + + // Traditional brackets alternate series within each round of games. + return ( + a.level - b.level || + (doubleElimination + ? a.set - b.set || a.number - b.number + : a.number - b.number || a.set - b.set) + ); + }) + .map(({ match }) => match); +} diff --git a/apps/worker/src/providers/first.ts b/apps/worker/src/providers/first.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/worker/src/providers/nexus.ts b/apps/worker/src/providers/nexus.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/worker/src/providers/tba.ts b/apps/worker/src/providers/tba.ts new file mode 100644 index 00000000..09ab3df1 --- /dev/null +++ b/apps/worker/src/providers/tba.ts @@ -0,0 +1,330 @@ +import { z } from "zod"; + +const baseUrl = "https://www.thebluealliance.com/api/v3"; + +export type CacheHeaders = { + etag?: string | null; + lastModified?: string | null; +}; + +export type TbaResponse = + | { + modified: true; + data: T; + etag: string | null; + lastModified: string | null; + } + | { modified: false; etag: string | null; lastModified: string | null }; + +type RequestOptions = CacheHeaders & { signal?: AbortSignal }; + +export const tbaStatusSchema = z.object({ + max_team_page: z.number().int().nonnegative(), +}); + +export const tbaTeamSchema = z.object({ + key: z.string().min(1), + team_number: z.number().int().positive(), + nickname: z.string().nullable(), + name: z.string(), + city: z.string().nullable(), + state_prov: z.string().nullable(), + country: z.string().nullable(), +}); + +export type TbaTeam = z.infer; + +export const tbaDistrictSchema = z.object({ + key: z.string().min(1), + year: z.number().int().min(1992), + abbreviation: z.string().min(1), + display_name: z.string().min(1), +}); + +export type TbaDistrict = z.infer; + +export const tbaTournamentSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + year: z.number().int().min(1992), + city: z.string().nullable(), + start_date: z.iso.date(), + end_date: z.iso.date(), + timezone: z.string().nullable(), + event_type: z.number().int(), + week: z.number().int().nonnegative().nullish(), + playoff_type: z.number().int().nullable(), + parent_event_key: z.string().nullable(), + district: z + .object({ + key: z.string().min(1), + year: z.number().int().min(1992), + abbreviation: z.string().min(1), + display_name: z.string().min(1), + }) + .nullable(), +}); + +export type TbaTournament = z.infer; + +const tbaAllianceSchema = z.object({ + team_keys: z.array(z.string().min(1)).max(3), + score: z.number().int().min(-1).nullable(), + dq_team_keys: z.array(z.string()), + surrogate_team_keys: z.array(z.string()), +}); + +export const tbaMatchSchema = z.object({ + key: z.string().min(1), + event_key: z.string().min(1), + comp_level: z.enum(["pm", "qm", "ef", "qf", "sf", "f"]), + set_number: z.number().int().nonnegative(), + match_number: z.number().int().positive(), + time: z.number().int().nonnegative().nullable(), + predicted_time: z.number().int().nonnegative().nullable(), + actual_time: z.number().int().nonnegative().nullable(), + post_result_time: z.number().int().nonnegative().nullable(), + winning_alliance: z.enum(["", "red", "blue"]), + alliances: z.object({ red: tbaAllianceSchema, blue: tbaAllianceSchema }), + score_breakdown: z + .object({ + red: z.record(z.string(), z.json()).nullable(), + blue: z.record(z.string(), z.json()).nullable(), + }) + .nullable(), +}); + +const tbaMatchEventSchema = z.object({ + key: z.string().min(1), + playoff_type: z.number().int().nullable(), + remap_teams: z.record(z.string(), z.string()).nullish(), +}); + +export type TbaMatch = z.infer; + +export const tbaSelectionsSchema = z.array( + z.object({ + picks: z.array(z.string().min(1)), + backup: z.object({ in: z.string(), out: z.string() }).nullish(), + }), +); + +export const tbaAwardsSchema = z.array( + z.object({ + award_type: z.number().int(), + name: z.string(), + event_key: z.string(), + recipient_list: z.array( + z.object({ + team_key: z.string().nullable(), + awardee: z.string().nullable(), + }), + ), + }), +); + +const tbaMediaSchema = z.array( + z.object({ + type: z.string(), + preferred: z.boolean().optional(), + details: z + .object({ base64Image: z.string().max(100_000).optional() }) + .nullish(), + }), +); + +export class TbaHttpError extends Error { + constructor( + public readonly resourceKey: string, + public readonly status: number, + public readonly retryAfter: string | null, + ) { + super(`TBA request failed: ${resourceKey} (HTTP ${status})`); + this.name = "TbaHttpError"; + } +} + +export function createTbaClient({ + apiKey = process.env.TBA_KEY, + timeoutMs = 15_000, + fetcher = fetch, +}: { + apiKey?: string; + timeoutMs?: number; + fetcher?: typeof fetch; +} = {}) { + if (!apiKey?.trim()) throw new Error("TBA_KEY is required"); + const authKey = apiKey; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new Error("TBA timeout must be a positive integer"); + } + + async function get( + resourceKey: string, + schema: z.ZodType, + options: RequestOptions = {}, + ): Promise> { + // Accept provider-relative paths only; never send credentials to another host. + if ( + !resourceKey || + resourceKey.startsWith("/") || + resourceKey.includes("#") + ) { + throw new Error("TBA resource key must be a relative endpoint path"); + } + const url = new URL(`${baseUrl}/${resourceKey}`); + if ( + url.origin !== new URL(baseUrl).origin || + !url.pathname.startsWith("/api/v3/") + ) { + throw new Error("TBA resource key must stay within /api/v3/"); + } + + const headers = new Headers({ "X-TBA-Auth-Key": authKey }); + if (options.etag) headers.set("If-None-Match", options.etag); + if (options.lastModified) + headers.set("If-Modified-Since", options.lastModified); + + const timeout = AbortSignal.timeout(timeoutMs); + const response = await fetcher(url, { + headers, + // Bun treats 304 as a redirect in error mode; manual also avoids forwarding credentials. + redirect: "manual", + signal: options.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout, + }); + const etag = response.headers.get("etag"); + const lastModified = response.headers.get("last-modified"); + + if (response.status === 304) { + if (!options.etag && !options.lastModified) { + throw new Error( + `TBA returned 304 without cache validators: ${resourceKey}`, + ); + } + return { + modified: false, + etag: etag ?? options.etag ?? null, + lastModified: lastModified ?? options.lastModified ?? null, + }; + } + if (!response.ok) { + throw new TbaHttpError( + resourceKey, + response.status, + response.headers.get("retry-after"), + ); + } + + return { + modified: true, + data: schema.parse(await response.json()), + etag, + lastModified, + }; + } + + return { + get, + + getDistricts(year: number, options?: RequestOptions) { + validateYear(year); + + return get(`districts/${year}`, z.array(tbaDistrictSchema), options); + }, + + getDistrictTeams(districtKey: string, options?: RequestOptions) { + return get( + `district/${encodeURIComponent(districtKey)}/teams/simple`, + z.array(tbaTeamSchema), + options, + ); + }, + + getTournamentTeams(eventKey: string, options?: RequestOptions) { + return get( + `event/${encodeURIComponent(eventKey)}/teams/simple`, + z.array(tbaTeamSchema), + options, + ); + }, + + getSeasonTeamsPage(year: number, page: number, options?: RequestOptions) { + validateYear(year); + + if (!Number.isSafeInteger(page) || page < 0) + throw new Error("TBA team page must be a nonnegative integer"); + + return get( + `teams/${year}/${page}/simple`, + z.array(tbaTeamSchema), + options, + ); + }, + + getMatchEvent(eventKey: string) { + return get(`event/${encodeURIComponent(eventKey)}`, tbaMatchEventSchema); + }, + + getMatches(eventKey: string, options?: RequestOptions) { + return get( + `event/${encodeURIComponent(eventKey)}/matches`, + z.array(tbaMatchSchema), + options, + ); + }, + + getSelections(eventKey: string, options?: RequestOptions) { + return get( + `event/${encodeURIComponent(eventKey)}/alliances`, + tbaSelectionsSchema, + options, + ); + }, + + getAwards(eventKey: string, options?: RequestOptions) { + return get( + `event/${encodeURIComponent(eventKey)}/awards`, + tbaAwardsSchema, + options, + ); + }, + + getTeamMedia(teamNumber: number, year: number, options?: RequestOptions) { + validateYear(year); + + return get( + `team/frc${teamNumber}/media/${year}`, + tbaMediaSchema, + options, + ); + }, + + getStatus: (options?: RequestOptions) => + get("status", tbaStatusSchema, options), + + getTournaments(year: number, options?: RequestOptions) { + if (!Number.isSafeInteger(year) || year < 1992) { + throw new Error( + "TBA tournament year must be an integer of 1992 or later", + ); + } + + return get(`events/${year}`, z.array(tbaTournamentSchema), options); + }, + + getTeamsPage(page: number, options?: RequestOptions) { + if (!Number.isSafeInteger(page) || page < 0) { + throw new Error("TBA team page must be a nonnegative integer"); + } + return get(`teams/${page}/simple`, z.array(tbaTeamSchema), options); + }, + }; +} + +function validateYear(year: number) { + if (!Number.isSafeInteger(year) || year < 1992 || year > 9999) { + throw new Error("TBA year must be an integer between 1992 and 9999"); + } +} diff --git a/apps/worker/src/schedule.ts b/apps/worker/src/schedule.ts new file mode 100644 index 00000000..9f7cc175 --- /dev/null +++ b/apps/worker/src/schedule.ts @@ -0,0 +1,72 @@ +import type { Tournament } from "@lovat/db"; + +export const minute = 60_000; +export const hour = 60 * minute; +export const day = 24 * hour; + +type EventDates = Pick; + +export function tournamentCadence(event: EventDates, now = new Date()) { + if (!event.startDate || !event.endDate) { + return { phase: "unknown", matches: day, roster: day } as const; + } + + let formatter: Intl.DateTimeFormat; + + try { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone: event.timezone ?? "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + }); + } catch { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + }); + } + + const parts = Object.fromEntries( + formatter.formatToParts(now).map((part) => [part.type, part.value]), + ); + const localDate = `${parts.year}-${parts.month}-${parts.day}`; + const start = event.startDate.toISOString().slice(0, 10); + const end = event.endDate.toISOString().slice(0, 10); + + if (localDate >= start && localDate <= end) { + const eventHours = Number(parts.hour) >= 7 && Number(parts.hour) < 21; + + return { + phase: "active", + matches: eventHours ? minute : 15 * minute, + roster: hour, + } as const; + } + + const today = Date.parse(`${localDate}T00:00:00Z`); + + if (localDate < start) { + const soon = event.startDate.getTime() - today <= 7 * day; + + return { + phase: "upcoming", + matches: soon ? 30 * minute : day, + roster: soon ? 6 * hour : day, + } as const; + } + + const recent = today - event.endDate.getTime() <= 7 * day; + + return { + phase: "finished", + matches: recent ? 6 * hour : 7 * day, + roster: recent ? day : 7 * day, + } as const; +} diff --git a/apps/worker/src/scheduler.ts b/apps/worker/src/scheduler.ts new file mode 100644 index 00000000..edbcd0e1 --- /dev/null +++ b/apps/worker/src/scheduler.ts @@ -0,0 +1,325 @@ +import type { ImportJob, PrismaClient } from "@lovat/db"; +import { importAllTeams } from "./jobs/teams"; +import { importTournaments } from "./jobs/tournaments"; +import { importTeamSeasons } from "./jobs/team-seasons"; +import { importDistricts, importDistrictTeams } from "./jobs/districts"; +import { importTournamentTeams } from "./jobs/tournament-teams"; +import { importTournamentDetails } from "./jobs/tournament-details"; +import { importMatches } from "./jobs/matches"; +import { refreshTournamentGaps } from "./jobs/gaps"; +import { validateSeason, validateTarget } from "./jobs/shared"; +import { TbaHttpError } from "./providers/tba"; +import { day, hour, minute, tournamentCadence } from "./schedule"; + +export const jobKinds = [ + "teams", + "tournaments", + "team-seasons", + "districts", + "district-teams", + "tournament-teams", + "tournament-details", + "matches", + "gaps", +] as const; +export type JobKind = (typeof jobKinds)[number]; + +const leaseMs = 90_000; + +export async function enqueueImport( + db: PrismaClient, + kind: JobKind, + targetKey: string, + runAt = new Date(), +) { + if (!jobKinds.includes(kind)) throw new Error("Unknown import kind"); + + validateTarget(targetKey); + + if (["tournaments", "team-seasons", "districts"].includes(kind)) + validateSeason(Number(targetKey)); + if (kind === "teams" && targetKey !== "global") + throw new Error("Team catalog target must be global"); + + // Preserve existing leases, retry delays and recurring run times. + return db.importJob.upsert({ + where: { kind_targetKey: { kind, targetKey } }, + update: {}, + create: { kind, targetKey, runAt }, + }); +} + +async function ensureRecurring( + db: PrismaClient, + kind: JobKind, + targetKey: string, + interval: number, + now: Date, + existing?: ImportJob, +) { + if (!existing) { + await enqueueImport(db, kind, targetKey, now); + return; + } + + const runAt = new Date(now.getTime() + interval); + + if ( + existing.attempts || + existing.lastError || + existing.runAt <= runAt || + (existing.leaseExpiresAt && existing.leaseExpiresAt > now) + ) + return; + + // Entering an active window may bring a previously slow refresh forward. + await db.importJob.updateMany({ + where: { + id: existing.id, + attempts: 0, + lastError: null, + runAt: { gt: runAt }, + OR: [{ leaseExpiresAt: null }, { leaseExpiresAt: { lte: now } }], + }, + data: { runAt }, + }); +} + +export async function reconcileSchedule( + db: PrismaClient, + now = new Date(), + year = now.getUTCFullYear(), +) { + validateSeason(year); + + const [districts, tournaments, jobs] = await Promise.all([ + db.districtSeason.findMany({ + where: { seasonYear: year }, + select: { key: true }, + }), + db.tournament.findMany({ + where: { seasonYear: year }, + select: { key: true, startDate: true, endDate: true, timezone: true }, + }), + db.importJob.findMany(), + ]); + + const existing = new Map( + jobs.map((job) => [`${job.kind}:${job.targetKey}`, job]), + ); + const ensure = (kind: JobKind, target: string, interval: number) => + ensureRecurring( + db, + kind, + target, + interval, + now, + existing.get(`${kind}:${target}`), + ); + + await ensure("teams", "global", 7 * day); + await ensure("tournaments", String(year), 6 * hour); + await ensure("team-seasons", String(year), 7 * day); + await ensure("districts", String(year), day); + + for (const district of districts) + await ensure("district-teams", district.key, day); + + for (const tournament of tournaments) { + const cadence = tournamentCadence(tournament, now); + + await ensure("tournament-teams", tournament.key, cadence.roster); + await ensure("tournament-details", tournament.key, cadence.roster); + await ensure("matches", tournament.key, cadence.matches); + } +} + +export async function claimJob(db: PrismaClient, now = new Date()) { + const token = crypto.randomUUID(); + const expires = new Date(now.getTime() + leaseMs); + + // Claim atomically so multiple worker processes cannot take the same lease. + const jobs = await db.$queryRaw` + UPDATE "ImportJob" AS job + SET "leaseToken" = ${token}, "leaseExpiresAt" = ${expires} + FROM ( + SELECT "id" FROM "ImportJob" + WHERE "runAt" <= ${now} + AND ("leaseExpiresAt" IS NULL OR "leaseExpiresAt" <= ${now}) + ORDER BY CASE + WHEN "kind" = 'matches' AND EXISTS ( + SELECT 1 FROM "Tournament" AS event + WHERE event."key" = "ImportJob"."targetKey" + AND event."startDate" <= ${now}::timestamp + INTERVAL '1 day' + AND event."endDate" >= ${now}::timestamp - INTERVAL '1 day' + ) THEN 0 + WHEN "kind" IN ('tournaments', 'districts') THEN 1 + ELSE 2 + END, "runAt", "id" + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) AS candidate + WHERE job."id" = candidate."id" + RETURNING job.* + `; + + return jobs[0] ?? null; +} + +export async function executeImport( + job: Pick, + db: PrismaClient, +) { + const { tba } = await import("./tba"); + const dependencies = { db, tba }; + const year = Number(job.targetKey); + + switch (job.kind) { + case "teams": + return importAllTeams(dependencies); + case "tournaments": + await importTournaments(year, year, dependencies); + return reconcileSchedule(db, new Date(), year); + case "team-seasons": + return importTeamSeasons(year, dependencies); + case "districts": + await importDistricts(year, dependencies); + return reconcileSchedule(db, new Date(), year); + case "district-teams": + return importDistrictTeams(job.targetKey, dependencies); + case "tournament-teams": + return importTournamentTeams(job.targetKey, dependencies); + case "tournament-details": + return importTournamentDetails(job.targetKey, dependencies); + case "matches": + return importMatches(job.targetKey, dependencies); + case "gaps": + return refreshTournamentGaps(job.targetKey, { db }); + default: + throw new Error("Unknown import kind"); + } +} + +async function refreshInterval(db: PrismaClient, job: ImportJob, now: Date) { + if (job.kind === "gaps") return day; + if (job.kind === "teams" || job.kind === "team-seasons") return 7 * day; + if (job.kind === "tournaments") return 6 * hour; + if (job.kind === "districts" || job.kind === "district-teams") return day; + + const event = await db.tournament.findUniqueOrThrow({ + where: { key: job.targetKey }, + }); + const cadence = tournamentCadence(event, now); + + return job.kind === "matches" ? cadence.matches : cadence.roster; +} + +export function retryDelay(attempts: number, error: unknown, now = new Date()) { + const backoff = Math.min(hour, minute * 2 ** Math.min(attempts, 6)); + + if (!(error instanceof TbaHttpError) || !error.retryAfter) return backoff; + + const seconds = Number(error.retryAfter); + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Date.parse(error.retryAfter) - now.getTime(); + + return Number.isFinite(delay) ? Math.max(backoff, delay) : backoff; +} + +export async function runNextJob( + db: PrismaClient, + execute: (job: ImportJob, db: PrismaClient) => Promise = executeImport, +) { + const job = await claimJob(db); + + if (!job) return false; + + let renewing = false; + let lostLease = false; + + const heartbeat = setInterval(async () => { + if (renewing || lostLease) return; + + renewing = true; + + try { + const now = new Date(); + const renewed = await db.importJob.updateMany({ + where: { + id: job.id, + leaseToken: job.leaseToken, + leaseExpiresAt: { gt: now }, + }, + data: { leaseExpiresAt: new Date(now.getTime() + leaseMs) }, + }); + + lostLease = renewed.count !== 1; + } catch { + lostLease = true; + } finally { + renewing = false; + } + }, 30_000); + + try { + await execute(job, db); + + const now = new Date(); + const interval = await refreshInterval(db, job, now); + + if (!lostLease) { + const completed = await db.importJob.updateMany({ + where: { + id: job.id, + leaseToken: job.leaseToken, + leaseExpiresAt: { gt: now }, + }, + data: { + runAt: new Date(now.getTime() + interval), + attempts: 0, + lastError: null, + leaseToken: null, + leaseExpiresAt: null, + }, + }); + if (completed.count === 1) { + console.info( + `Import completed: ${job.kind} ${job.targetKey}; next refresh ${new Date(now.getTime() + interval).toISOString()}`, + ); + } + } + } catch (error) { + const now = new Date(); + // Avoid persisting raw upstream bodies or database errors containing secrets. + const lastError = + error instanceof TbaHttpError + ? error.message + : error instanceof Error + ? error.name + : "Unknown import error"; + + if (!lostLease) { + await db.importJob.updateMany({ + where: { + id: job.id, + leaseToken: job.leaseToken, + leaseExpiresAt: { gt: now }, + }, + data: { + runAt: new Date(now.getTime() + retryDelay(job.attempts, error, now)), + attempts: { increment: 1 }, + lastError, + leaseToken: null, + leaseExpiresAt: null, + }, + }); + } + + console.error(`Import failed: ${job.kind} ${job.targetKey} (${lastError})`); + } finally { + clearInterval(heartbeat); + } + + return true; +} diff --git a/apps/worker/src/server.ts b/apps/worker/src/server.ts new file mode 100644 index 00000000..461ed667 --- /dev/null +++ b/apps/worker/src/server.ts @@ -0,0 +1,34 @@ +import { Hono } from "hono"; + +export function createHealthApp({ + isReady, + checkDatabase, +}: { + isReady: () => boolean; + checkDatabase: () => Promise; +}) { + const app = new Hono(); + + app.get("/health", async (c) => { + if (!isReady()) return c.json({ status: "unavailable" }, 503); + + try { + await checkDatabase(); + return c.json({ status: "ok" }); + } catch { + return c.json({ status: "unavailable" }, 503); + } + }); + + return app; +} + +export function healthPort(value = process.env.PORT ?? "8080") { + const port = Number(value); + + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + throw new Error("PORT must be an integer between 1 and 65535"); + } + + return port; +} diff --git a/apps/worker/src/tba.ts b/apps/worker/src/tba.ts new file mode 100644 index 00000000..55cc2fe6 --- /dev/null +++ b/apps/worker/src/tba.ts @@ -0,0 +1,3 @@ +import { createTbaClient } from "./providers/tba"; + +export const tba = createTbaClient(); diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts new file mode 100644 index 00000000..52bc9b34 --- /dev/null +++ b/apps/worker/src/worker.ts @@ -0,0 +1,62 @@ +import type { PrismaClient } from "@lovat/db"; +import { reconcileSchedule, runNextJob } from "./scheduler"; + +function sleep(ms: number, signal: AbortSignal) { + if (signal.aborted) return Promise.resolve(); + + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + + const timer = setTimeout(finish, ms); + signal.addEventListener("abort", finish, { once: true }); + }); +} + +export async function runWorker( + db: PrismaClient, + signal: AbortSignal, + concurrency = 2, + onReconciled: () => void = () => {}, +) { + if ( + !Number.isSafeInteger(concurrency) || + concurrency < 1 || + concurrency > 16 + ) { + throw new Error("Worker concurrency must be an integer between 1 and 16"); + } + + const planner = async () => { + while (!signal.aborted) { + try { + await reconcileSchedule(db); + onReconciled(); + } catch { + console.error("Schedule reconciliation failed; retrying in 30 seconds"); + } + + await sleep(30_000, signal); + } + }; + + const consumer = async () => { + while (!signal.aborted) { + try { + if (await runNextJob(db)) continue; + } catch { + console.error("Job claim or completion failed; retrying in 5 seconds"); + } + + await sleep(5_000, signal); + } + }; + + await Promise.all([ + planner(), + ...Array.from({ length: concurrency }, consumer), + ]); +} diff --git a/apps/worker/test/avatar.test.ts b/apps/worker/test/avatar.test.ts new file mode 100644 index 00000000..f7ba66fc --- /dev/null +++ b/apps/worker/test/avatar.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { avatarDataUri } from "../src/jobs/avatar"; + +test("recognizes raster formats and normalizes whitespace", () => { + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII="; + + expect(avatarDataUri(`\n${png}\n`)).toBe(`data:image/png;base64,${png}`); + expect( + avatarDataUri(Buffer.from([255, 216, 255, 224]).toString("base64")), + ).toStartWith("data:image/jpeg;base64,"); + expect(avatarDataUri(Buffer.from("GIF89a").toString("base64"))).toStartWith( + "data:image/gif;base64,", + ); + expect( + avatarDataUri(Buffer.from("RIFFxxxxWEBP").toString("base64")), + ).toStartWith("data:image/webp;base64,"); +}); + +test("unavailable or non-raster media uses the number-tile fallback", () => { + expect(avatarDataUri(undefined)).toBeNull(); + expect(avatarDataUri("not!base64")).toBeNull(); + expect( + avatarDataUri(Buffer.from("").toString("base64")), + ).toBeNull(); +}); diff --git a/apps/worker/test/catalog.integration.test.ts b/apps/worker/test/catalog.integration.test.ts new file mode 100644 index 00000000..a9bf43fe --- /dev/null +++ b/apps/worker/test/catalog.integration.test.ts @@ -0,0 +1,305 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { PrismaClient } from "@lovat/db"; +import { importTeamSeasons } from "../src/jobs/team-seasons"; +import { importDistricts, importDistrictTeams } from "../src/jobs/districts"; +import { importTournamentTeams } from "../src/jobs/tournament-teams"; +import type { TbaTeam } from "../src/providers/tba"; + +const enabled = process.env.LOVAT_DB_TEST === "1"; +const fixture = crypto.randomUUID(); +const year = 2093; +const number = 5_000_000 + Math.floor(Math.random() * 1_000_000); +const eventKey = `${fixture}-event`; +const districtKey = `${fixture}-district`; + +let db: PrismaClient; + +const team = (teamNumber = number): TbaTeam => ({ + key: `frc${teamNumber}`, + team_number: teamNumber, + name: "Official name", + nickname: "Season name", + city: "San Jose", + state_prov: "CA", + country: "USA", +}); +const response = (data: T, etag = "v1") => ({ + modified: true as const, + data, + etag, + lastModified: null, +}); + +beforeAll(async () => { + if (!enabled) return; + + const url = new URL( + process.env.DATABASE_URL ?? "postgresql://localhost/missing", + ); + + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + url.pathname !== "/lovat_test" + ) + throw new Error("Use only a disposable local lovat_test database"); + + const { createDb } = await import("@lovat/db"); + db = createDb(url.href); + + await db.team.create({ data: { number, name: "Current global name" } }); +}); + +afterAll(async () => { + if (!db) return; + + try { + await db.tournament.deleteMany({ where: { key: eventKey } }); + await db.teamSeason.deleteMany({ + where: { teamNumber: { in: [number, number + 1] }, seasonYear: year }, + }); + await db.districtSeason.deleteMany({ where: { key: districtKey } }); + await db.team.deleteMany({ + where: { number: { in: [number, number + 1] } }, + }); + await db.season.deleteMany({ + where: { + year, + tournaments: { none: {} }, + teamSeasons: { none: {} }, + districtSeasons: { none: {} }, + }, + }); + await db.fetchState.deleteMany({ + where: { + provider: "tba", + OR: [ + { resourceKey: { contains: fixture } }, + { + resourceKey: { + in: [ + `districts/${year}`, + ...[0, 1, 2].map((page) => `teams/${year}/${page}/simple`), + ], + }, + }, + ], + }, + }); + } finally { + await db.$disconnect(); + } +}); + +test.skipIf(!enabled)( + "imports season pages through empty pages without overwriting global names or memberships", + async () => { + const pages: number[] = []; + + await importTeamSeasons(year, { + db, + tba: { + async getStatus() { + return response({ max_team_page: 2 }); + }, + async getSeasonTeamsPage(requestYear, page) { + expect(requestYear).toBe(year); + pages.push(page); + return response(page === 1 ? [team()] : []); + }, + }, + }); + + expect(pages).toEqual([0, 1, 2]); + expect((await db.team.findUniqueOrThrow({ where: { number } })).name).toBe( + "Current global name", + ); + expect( + ( + await db.teamSeason.findUniqueOrThrow({ + where: { + teamNumber_seasonYear: { teamNumber: number, seasonYear: year }, + }, + }) + ).city, + ).toBe("San Jose"); + }, +); + +test.skipIf(!enabled)( + "imports district catalogs and reconciles memberships without deleting season teams", + async () => { + await importDistricts(year, { + db, + tba: { + async getDistricts() { + return response([ + { + key: districtKey, + year, + abbreviation: fixture, + display_name: "District fixture", + }, + ]); + }, + }, + }); + + await importDistrictTeams(districtKey, { + db, + tba: { + async getDistrictTeams() { + return response([team()]); + }, + }, + }); + + expect( + ( + await db.teamSeason.findUniqueOrThrow({ + where: { + teamNumber_seasonYear: { teamNumber: number, seasonYear: year }, + }, + }) + ).districtSeasonKey, + ).toBe(districtKey); + + await importTeamSeasons(year, { + db, + tba: { + async getStatus() { + return response({ max_team_page: 0 }); + }, + async getSeasonTeamsPage() { + return response([team()], "updated"); + }, + }, + }); + + expect( + ( + await db.teamSeason.findUniqueOrThrow({ + where: { + teamNumber_seasonYear: { teamNumber: number, seasonYear: year }, + }, + }) + ).districtSeasonKey, + ).toBe(districtKey); + + await importDistrictTeams(districtKey, { + db, + tba: { + async getDistrictTeams() { + return response([], "v2"); + }, + }, + }); + + expect( + ( + await db.teamSeason.findUniqueOrThrow({ + where: { + teamNumber_seasonYear: { teamNumber: number, seasonYear: year }, + }, + }) + ).districtSeasonKey, + ).toBeNull(); + }, +); + +test.skipIf(!enabled)( + "rosters apply remappings, remove stale memberships and retain match slots", + async () => { + await db.tournament.create({ + data: { key: eventKey, name: "Event fixture", seasonYear: year }, + }); + await db.teamMatchData.create({ + data: { + key: `${eventKey}_qm1_0`, + tournamentKey: eventKey, + teamNumber: number, + matchNumber: 1, + matchType: "QUALIFICATION", + }, + }); + + const metadata = { + key: eventKey, + playoff_type: 10, + remap_teams: { [`frc${number}`]: `frc${number}B` }, + }; + const tba = { + async getMatchEvent() { + return response(metadata); + }, + async getTournamentTeams() { + return response([ + { ...team(), key: `frc${number}B` }, + team(number + 1), + ]); + }, + }; + + await importTournamentTeams(eventKey, { db, tba }); + expect( + await db.teamTournament.count({ where: { tournamentKey: eventKey } }), + ).toBe(2); + + await importTournamentTeams(eventKey, { + db, + tba: { + ...tba, + async getTournamentTeams(_key, options) { + expect(options?.etag).toBe("v1"); + return response([team(number + 1)], "v2"); + }, + }, + }); + + expect( + await db.teamTournament.count({ where: { tournamentKey: eventKey } }), + ).toBe(1); + expect( + await db.teamMatchData.findUnique({ + where: { key: `${eventKey}_qm1_0` }, + }), + ).not.toBeNull(); + + await expect( + importTournamentTeams(eventKey, { + db, + tba: { + ...tba, + async getTournamentTeams() { + return response( + [team(number), { ...team(number + 1), key: "unmapped-alias" }], + "bad", + ); + }, + }, + }), + ).rejects.toThrow("mapping"); + + expect( + await db.teamTournament.count({ where: { tournamentKey: eventKey } }), + ).toBe(1); + expect( + ( + await db.fetchState.findUniqueOrThrow({ + where: { + provider_resourceKey: { + provider: "tba", + resourceKey: `event/${eventKey}/teams/simple`, + }, + }, + }) + ).etag, + ).toBe("v2"); + }, +); + +test("rejects invalid seasons and targets before loading clients", async () => { + await expect(importTeamSeasons(1991)).rejects.toThrow("Season"); + await expect(importDistricts(NaN)).rejects.toThrow("Season"); + await expect(importDistrictTeams("")).rejects.toThrow("required"); + await expect(importTournamentTeams(" ")).rejects.toThrow("required"); +}); diff --git a/apps/worker/test/gaps.integration.test.ts b/apps/worker/test/gaps.integration.test.ts new file mode 100644 index 00000000..20faf693 --- /dev/null +++ b/apps/worker/test/gaps.integration.test.ts @@ -0,0 +1,211 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { PrismaClient } from "@lovat/db"; +import { importMatches } from "../src/jobs/matches"; +import { refreshTournamentGaps } from "../src/jobs/gaps"; +import type { TbaMatch } from "../src/providers/tba"; + +const enabled = process.env.LOVAT_DB_TEST === "1"; +const eventKey = `gaps-${crypto.randomUUID()}`; + +let db: PrismaClient; + +const matches: TbaMatch[] = [1, 2].map((number) => ({ + key: `${eventKey}_qm${number}`, + event_key: eventKey, + comp_level: "qm", + set_number: 1, + match_number: number, + time: Date.parse(`2026-03-06T${number === 1 ? "19" : "20"}:00:00Z`) / 1000, + actual_time: null, + predicted_time: null, + post_result_time: null, + winning_alliance: "", + score_breakdown: null, + alliances: { + red: { + team_keys: [], + score: -1, + dq_team_keys: [], + surrogate_team_keys: [], + }, + blue: { + team_keys: [], + score: -1, + dq_team_keys: [], + surrogate_team_keys: [], + }, + }, +})); + +const tba = { + async getMatchEvent() { + return { + modified: true as const, + data: { key: eventKey, playoff_type: 10, remap_teams: null }, + etag: "event", + lastModified: null, + }; + }, + async getMatches() { + return { + modified: true as const, + data: matches, + etag: "matches", + lastModified: null, + }; + }, +}; + +beforeAll(async () => { + if (!enabled) return; + + const url = new URL( + process.env.DATABASE_URL ?? "postgresql://localhost/missing", + ); + + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + url.pathname !== "/lovat_test" + ) + throw new Error("Use only a disposable local lovat_test database"); + + const { createDb } = await import("@lovat/db"); + db = createDb(url.href); + + await db.tournament.create({ + data: { + key: eventKey, + name: "Gap fixture", + timezone: "America/Los_Angeles", + }, + }); +}); + +afterAll(async () => { + if (!db) return; + + try { + await db.tournament.delete({ where: { key: eventKey } }); + await db.fetchState.deleteMany({ + where: { + provider: "tba", + resourceKey: { startsWith: `event/${eventKey}/` }, + }, + }); + } finally { + await db.$disconnect(); + } +}); + +test.skipIf(!enabled)( + "match imports commit gaps and cached responses backfill them idempotently", + async () => { + await importMatches(eventKey, { db, tba }); + + const gap = await db.tournamentGap.findFirstOrThrow({ + where: { tournamentKey: eventKey }, + }); + expect(gap.type).toBe("LUNCH"); + expect(gap.timingSource).toBe("SCHEDULED"); + + await db.tournamentGap.deleteMany({ where: { tournamentKey: eventKey } }); + + const cached = { + ...tba, + async getMatches() { + return { + modified: false as const, + etag: "matches", + lastModified: null, + }; + }, + }; + + await importMatches(eventKey, { db, tba: cached }); + await refreshTournamentGaps(eventKey, { db }); + expect( + await db.tournamentGap.count({ where: { tournamentKey: eventKey } }), + ).toBe(1); + }, +); + +test.skipIf(!enabled)( + "failed imports preserve gaps and actual corrections remove stale gaps", + async () => { + const prior = await db.tournamentGap.findFirstOrThrow({ + where: { tournamentKey: eventKey }, + }); + + await expect( + importMatches(eventKey, { + db, + tba: { + ...tba, + async getMatches() { + return { + modified: true as const, + data: [ + { ...matches[0]!, time: matches[0]!.time! - 3600 }, + { ...matches[1]!, event_key: "wrong-event" }, + ], + etag: "bad", + lastModified: null, + }; + }, + }, + }), + ).rejects.toThrow("belong"); + + expect( + await db.tournamentGap.findFirstOrThrow({ + where: { tournamentKey: eventKey }, + }), + ).toEqual(prior); + + matches[0]!.actual_time = matches[0]!.time; + matches[1]!.actual_time = matches[0]!.time! + 600; + + await importMatches(eventKey, { db, tba }); + expect( + await db.tournamentGap.count({ where: { tournamentKey: eventKey } }), + ).toBe(0); + }, +); + +test.skipIf(!enabled)( + "database rejects inverted intervals and cascades gaps with boundary matches", + async () => { + const data = { + tournamentKey: eventKey, + afterMatchKey: matches[0]!.key, + beforeMatchKey: matches[1]!.key, + type: "BREAK" as const, + timingSource: "ACTUAL" as const, + startTime: new Date("2026-03-06T19:03:00Z"), + endTime: new Date("2026-03-06T20:00:00Z"), + }; + + await expect( + Promise.resolve( + db.tournamentGap.create({ data: { ...data, endTime: data.startTime } }), + ), + ).rejects.toThrow(); + await expect( + Promise.resolve( + db.tournamentGap.create({ + data: { ...data, beforeMatchKey: data.afterMatchKey }, + }), + ), + ).rejects.toThrow(); + await db.tournamentGap.create({ data }); + await db.match.delete({ where: { key: matches[1]!.key } }); + + expect( + await db.tournamentGap.count({ where: { tournamentKey: eventKey } }), + ).toBe(0); + }, +); + +test("gap backfills reject empty tournament keys before loading clients", async () => { + await expect(refreshTournamentGaps(" ")).rejects.toThrow("required"); +}); diff --git a/apps/worker/test/gaps.test.ts b/apps/worker/test/gaps.test.ts new file mode 100644 index 00000000..0c73d517 --- /dev/null +++ b/apps/worker/test/gaps.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import { inferTournamentGaps } from "../src/gaps"; + +type Timing = Parameters[2][number]; +const eventKey = "2026fixture"; +const timezone = "America/Los_Angeles"; + +function match(number: number, time: string | null): Timing { + return { + key: `${eventKey}_qm${number}`, + competitionLevel: "QUALIFICATION", + setNumber: 1, + matchNumber: number, + scheduledTime: time ? new Date(time) : null, + actualTime: null, + predictedTime: null, + postResultTime: null, + }; +} + +test("identifies scheduled lunch in local time and estimates the preceding match duration", () => { + const after = match(1, "2026-03-06T19:00:00Z"); + const before = match(2, "2026-03-06T20:00:00Z"); + const gaps = inferTournamentGaps(eventKey, timezone, [before, after]); + + expect(gaps).toHaveLength(1); + expect(gaps[0]).toMatchObject({ + type: "LUNCH", + timingSource: "SCHEDULED", + afterMatchKey: after.key, + beforeMatchKey: before.key, + startTime: new Date("2026-03-06T19:03:00Z"), + endTime: before.scheduledTime, + }); +}); + +test("overnight marks EOD/new-day boundaries across daylight saving time", () => { + const gaps = inferTournamentGaps(eventKey, timezone, [ + match(1, "2026-03-08T01:00:00Z"), + match(2, "2026-03-08T16:00:00Z"), + ]); + + expect(gaps[0]?.type).toBe("OVERNIGHT"); +}); + +test("identifies playoff transition gaps at the shorter threshold", () => { + const after = match(1, "2026-03-06T19:00:00Z"); + const before = { + ...match(1, "2026-03-06T19:20:00Z"), + key: `${eventKey}_sf1m1`, + competitionLevel: "SEMIFINAL" as const, + }; + + expect( + inferTournamentGaps(eventKey, timezone, [before, after])[0]?.type, + ).toBe("PLAYOFF_TRANSITION"); +}); + +test("actual unplanned interruptions are delays even around lunchtime", () => { + const after = match(1, "2026-03-06T19:00:00Z"); + const before = match(2, "2026-03-06T19:08:00Z"); + after.actualTime = new Date("2026-03-06T19:00:00Z"); + after.postResultTime = new Date("2026-03-06T19:04:00Z"); + before.actualTime = new Date("2026-03-06T19:50:00Z"); + const gaps = inferTournamentGaps(eventKey, timezone, [after, before]); + + expect(gaps[0]).toMatchObject({ + type: "DELAY", + timingSource: "ACTUAL", + startTime: after.postResultTime, + endTime: before.actualTime, + }); +}); + +test("uses actual timing to remove a planned gap that did not occur", () => { + const after = match(1, "2026-03-06T19:00:00Z"); + const before = match(2, "2026-03-06T20:00:00Z"); + after.actualTime = new Date("2026-03-06T19:00:00Z"); + before.actualTime = new Date("2026-03-06T19:10:00Z"); + + expect(inferTournamentGaps(eventKey, timezone, [after, before])).toEqual([]); +}); + +test("retains unknown breaks without guessing local classifications", () => { + const matches = [ + match(1, "2026-03-06T19:00:00Z"), + match(2, "2026-03-06T20:00:00Z"), + ]; + + expect(inferTournamentGaps(eventKey, null, matches)[0]?.type).toBe("BREAK"); + expect(inferTournamentGaps(eventKey, "invalid", matches)[0]?.type).toBe( + "BREAK", + ); + expect( + inferTournamentGaps(eventKey, timezone, [ + match(1, "2026-03-06T16:00:00Z"), + match(2, "2026-03-06T17:00:00Z"), + ])[0]?.type, + ).toBe("BREAK"); +}); + +test("does not bridge missing matches, mix clocks or invent a trailing EOD", () => { + const after = match(1, "2026-03-06T19:00:00Z"); + const before = match(3, "2026-03-06T20:00:00Z"); + + expect( + inferTournamentGaps(eventKey, timezone, [after, match(2, null), before]), + ).toEqual([]); + expect(inferTournamentGaps(eventKey, timezone, [after, before])).toEqual([]); + expect(inferTournamentGaps(eventKey, timezone, [after])).toEqual([]); + after.scheduledTime = null; + after.actualTime = new Date("2026-03-06T19:00:00Z"); + + expect( + inferTournamentGaps(eventKey, timezone, [ + after, + { ...before, key: `${eventKey}_qm2`, matchNumber: 2 }, + ]), + ).toEqual([]); +}); + +test("uses predicted times only when comparable actual and scheduled pairs are absent", () => { + const after = match(1, null); + const before = match(2, null); + after.predictedTime = new Date("2026-03-06T19:00:00Z"); + before.predictedTime = new Date("2026-03-06T20:00:00Z"); + + expect( + inferTournamentGaps(eventKey, timezone, [after, before])[0]?.timingSource, + ).toBe("PREDICTED"); + before.predictedTime = new Date("2026-03-06T18:00:00Z"); + + expect(inferTournamentGaps(eventKey, timezone, [after, before])).toEqual([]); +}); + +test("historical bracket gaps connect adjacent games rather than whole series", () => { + const bracket = (set: number, number: number, time: string): Timing => ({ + ...match(number, time), + key: `${eventKey}_qf${set}m${number}`, + competitionLevel: "QUARTERFINAL", + setNumber: set, + }); + const matches = [ + bracket(1, 1, "2026-03-06T16:00:00Z"), + bracket(1, 2, "2026-03-06T17:00:00Z"), + bracket(2, 1, "2026-03-06T16:08:00Z"), + bracket(2, 2, "2026-03-06T17:08:00Z"), + ]; + const gaps = inferTournamentGaps(eventKey, timezone, matches); + + expect(gaps).toHaveLength(1); + expect(gaps[0]).toMatchObject({ + afterMatchKey: `${eventKey}_qf2m1`, + beforeMatchKey: `${eventKey}_qf1m2`, + startTime: new Date("2026-03-06T16:11:00Z"), + endTime: new Date("2026-03-06T17:00:00Z"), + }); +}); diff --git a/apps/worker/test/match-order.test.ts b/apps/worker/test/match-order.test.ts new file mode 100644 index 00000000..7f12e1f9 --- /dev/null +++ b/apps/worker/test/match-order.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { orderMatches } from "../src/match-order"; + +function match(set: number, number: number, scheduled: number | null = null) { + return { level: 1, set, number, scheduled, actual: null, display: null }; +} + +test("traditional brackets alternate series even before times are published", () => { + const matches = [match(1, 2), match(2, 1), match(2, 2), match(1, 1)]; + + expect(orderMatches(matches, (value) => value)).toEqual([ + matches[3]!, + matches[1]!, + matches[0]!, + matches[2]!, + ]); +}); + +test("double elimination keeps numbered bracket games before finals", () => { + const matches = [match(2, 1), match(1, 1), match(3, 1)]; + + expect(orderMatches(matches, (value) => value, true)).toEqual([ + matches[1]!, + matches[0]!, + matches[2]!, + ]); +}); + +test("actual sequence wins over scheduled sequence when every match has started", () => { + const matches = [ + { ...match(1, 1, 10), actual: 30 }, + { ...match(2, 1, 20), actual: 20 }, + ]; + + expect(orderMatches(matches, (value) => value)).toEqual( + [...matches].reverse(), + ); +}); + +test("partial actual timing does not reorder remaining scheduled matches", () => { + const matches = [{ ...match(1, 1, 10), actual: 30 }, match(2, 1, 20)]; + + expect(orderMatches(matches, (value) => value)).toEqual(matches); +}); + +test("imported display order is used when a complete timing clock is unavailable", () => { + const matches = [ + { ...match(1, 1), display: 2 }, + { ...match(2, 1, 20), display: 1 }, + ]; + + expect(orderMatches(matches, (value) => value)).toEqual( + [...matches].reverse(), + ); +}); diff --git a/apps/worker/test/matches.integration.test.ts b/apps/worker/test/matches.integration.test.ts new file mode 100644 index 00000000..afb1c07d --- /dev/null +++ b/apps/worker/test/matches.integration.test.ts @@ -0,0 +1,373 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { PrismaClient } from "@lovat/db"; +import { importMatches } from "../src/jobs/matches"; +import type { TbaMatch } from "../src/providers/tba"; + +const enabled = process.env.LOVAT_DB_TEST === "1"; +const eventKey = `match-fixture-${crypto.randomUUID()}`; +const teamNumber = 3_000_000 + Math.floor(Math.random() * 1_000_000); + +let db: PrismaClient; +let reportId: string; + +const match = (suffix = "qm1"): TbaMatch => ({ + key: `${eventKey}_${suffix}`, + event_key: eventKey, + comp_level: "qm", + set_number: 1, + match_number: 1, + time: 1_770_000_000, + predicted_time: null, + actual_time: null, + post_result_time: null, + winning_alliance: "", + alliances: { + red: { + team_keys: [`frc${teamNumber}B`], + score: -1, + dq_team_keys: [], + surrogate_team_keys: [`frc${teamNumber}B`], + }, + blue: { + team_keys: [`frc${teamNumber + 1}`], + score: -1, + dq_team_keys: [], + surrogate_team_keys: [], + }, + }, + score_breakdown: null, +}); + +function provider( + data: TbaMatch[], + eventEtag = "event-v1", + matchEtag = "matches-v1", +) { + return { + async getMatchEvent() { + return { + modified: true as const, + data: { + key: eventKey, + playoff_type: 10, + remap_teams: { [`frc${teamNumber}`]: `frc${teamNumber}B` }, + }, + etag: eventEtag, + lastModified: null, + }; + }, + + async getMatches() { + return { + modified: true as const, + data, + etag: matchEtag, + lastModified: null, + }; + }, + }; +} + +beforeAll(async () => { + if (!enabled) return; + + const url = new URL( + process.env.DATABASE_URL ?? "postgresql://localhost/missing", + ); + + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + url.pathname !== "/lovat_test" + ) { + throw new Error("Use only a disposable local lovat_test database"); + } + + const { createDb } = await import("@lovat/db"); + db = createDb(url.href); + + await db.tournament.create({ + data: { key: eventKey, name: "Match fixture" }, + }); + await db.team.create({ data: { number: teamNumber, name: "Original name" } }); + await db.teamMatchData.create({ + data: { + key: `${eventKey}_qm1_0`, + tournamentKey: eventKey, + teamNumber, + matchType: "QUALIFICATION", + matchNumber: 1, + }, + }); + + const scouter = await db.scouter.create({ + data: { + sourceTeam: { + create: { + number: teamNumber, + code: crypto.randomUUID(), + email: "fixture@example.invalid", + }, + }, + }, + }); + + const report = await db.scoutReport.create({ + data: { + teamMatchKey: `${eventKey}_qm1_0`, + scouterUuid: scouter.uuid, + startTime: new Date(), + notes: "Fixture report", + robotRoles: [], + driverAbility: 0, + beached: "NEITHER", + defenseEffectiveness: 0, + feederTypes: [], + intakeType: "NEITHER", + fieldTraversal: "NONE", + scoresWhileMoving: false, + disrupts: false, + endgameClimb: "NOT_ATTEMPTED", + autoClimb: "NOT_ATTEMPTED", + }, + }); + + reportId = report.uuid; +}); + +afterAll(async () => { + if (!db) return; + + try { + await db.tournament.delete({ where: { key: eventKey } }); + await db.team.deleteMany({ + where: { number: { in: [teamNumber, teamNumber + 1] } }, + }); + await db.fetchState.deleteMany({ + where: { + provider: "tba", + resourceKey: { + in: [ + `event/${eventKey}/matches?dependency=event`, + `event/${eventKey}/matches`, + ], + }, + }, + }); + } finally { + await db.$disconnect(); + } +}); + +test.skipIf(!enabled)( + "imports remapped participants, preserves scouting links and updates results idempotently", + async () => { + const qualification = match(); + const final = { ...match("f1m1"), comp_level: "f" as const }; + const practice = { ...match("pm1"), comp_level: "pm" as const }; + + await importMatches(eventKey, { + db, + tba: provider([final, practice, qualification]), + }); + + const slot = await db.teamMatchData.findUniqueOrThrow({ + where: { key: `${eventKey}_qm1_0` }, + include: { scoutReports: true }, + }); + expect(slot.matchKey).toBe(qualification.key); + expect(slot.externalParticipantKey).toBe(`frc${teamNumber}B`); + expect(slot.surrogate).toBe(true); + expect(slot.scoutReports[0]?.uuid).toBe(reportId); + expect( + (await db.team.findUniqueOrThrow({ where: { number: teamNumber } })).name, + ).toBe("Original name"); + expect( + ( + await db.teamMatchData.findUniqueOrThrow({ + where: { key: `${eventKey}_em14_0` }, + }) + ).matchKey, + ).toBe(final.key); + expect( + await db.match.findUnique({ where: { key: practice.key } }), + ).toBeNull(); + + qualification.alliances.red.score = 100; + qualification.alliances.blue.score = 90; + qualification.winning_alliance = "red"; + qualification.score_breakdown = { + red: { totalPoints: 100, nested: { auto: 20 } }, + blue: { totalPoints: 90 }, + }; + + await importMatches(eventKey, { + db, + tba: provider([qualification, final], "event-v1", "matches-v2"), + }); + + const saved = await db.match.findUniqueOrThrow({ + where: { key: qualification.key }, + include: { alliances: true }, + }); + expect(saved.status).toBe("COMPLETED"); + expect(saved.winningAlliance).toBe("RED"); + expect(saved.scheduledTime?.toISOString()).toBe( + new Date(qualification.time! * 1000).toISOString(), + ); + expect( + saved.alliances.find((alliance) => alliance.color === "RED") + ?.scoreBreakdown, + ).toEqual(qualification.score_breakdown.red); + expect(await db.match.count({ where: { tournamentKey: eventKey } })).toBe( + 2, + ); + expect( + await db.teamMatchData.count({ where: { tournamentKey: eventKey } }), + ).toBe(4); + }, +); + +test.skipIf(!enabled)( + "reuses match validators only when event metadata is unchanged", + async () => { + const before = await db.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + }); + const tba = { + ...provider([]), + async getMatches(_key: string, options?: { etag?: string | null }) { + expect(options?.etag).toBe("matches-v2"); + return { + modified: false as const, + etag: "matches-v2", + lastModified: null, + }; + }, + }; + + await importMatches(eventKey, { db, tba }); + expect( + (await db.tournament.findUniqueOrThrow({ where: { key: eventKey } })) + .officialDataRevision, + ).toBe(before.officialDataRevision); + + await importMatches(eventKey, { + db, + tba: { + ...provider([match()], "event-v2"), + async getMatches(_key, options) { + expect(options?.etag).toBeUndefined(); + return provider([match()], "event-v2", "matches-v3").getMatches(); + }, + }, + }); + }, +); + +test.skipIf(!enabled)( + "participant changes with reports roll back results and cache headers", + async () => { + const before = await db.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + }); + const changed = match(); + changed.alliances.red.team_keys = [`frc${teamNumber + 1}`]; + + await expect( + importMatches(eventKey, { + db, + tba: provider([changed], "event-v3", "bad"), + }), + ).rejects.toThrow("reconciliation"); + + expect( + ( + await db.teamMatchData.findUniqueOrThrow({ + where: { key: `${eventKey}_qm1_0` }, + }) + ).teamNumber, + ).toBe(teamNumber); + expect( + await db.scoutReport.findUnique({ where: { uuid: reportId } }), + ).not.toBeNull(); + expect( + ( + await db.fetchState.findUniqueOrThrow({ + where: { + provider_resourceKey: { + provider: "tba", + resourceKey: `event/${eventKey}/matches`, + }, + }, + }) + ).etag, + ).toBe("matches-v3"); + expect( + (await db.tournament.findUniqueOrThrow({ where: { key: eventKey } })) + .officialDataRevision, + ).toBe(before.officialDataRevision); + + changed.alliances.red.team_keys = []; + await expect( + importMatches(eventKey, { + db, + tba: provider([changed], "event-v3", "bad"), + }), + ).rejects.toThrow("reconciliation"); + }, +); + +test("rejects empty tournament keys before loading clients", async () => { + await expect(importMatches(" ")).rejects.toThrow("required"); +}); + +test.skipIf(!enabled)( + "imports historical bracket display order and gaps in play sequence", + async () => { + const games = [ + [1, 1, 0], + [1, 2, 3_600], + [2, 1, 480], + [2, 2, 4_080], + ].map(([set, number, offset]) => ({ + ...match(`qf${set}m${number}`), + comp_level: "qf" as const, + set_number: set!, + match_number: number!, + time: 1_770_000_000 + offset!, + })); + const tba = provider(games, "historical-event", "historical-matches"); + const event = await tba.getMatchEvent(); + + await importMatches(eventKey, { + db, + tba: { + ...tba, + getMatchEvent: async () => ({ + ...event, + data: { ...event.data, playoff_type: 0 }, + }), + }, + }); + + const imported = await db.match.findMany({ + where: { key: { in: games.map((game) => game.key) } }, + orderBy: { displayOrder: "asc" }, + }); + + expect(imported.map((game) => game.key)).toEqual([ + `${eventKey}_qf1m1`, + `${eventKey}_qf2m1`, + `${eventKey}_qf1m2`, + `${eventKey}_qf2m2`, + ]); + expect( + await db.tournamentGap.findFirst({ + where: { + afterMatchKey: `${eventKey}_qf2m1`, + beforeMatchKey: `${eventKey}_qf1m2`, + }, + }), + ).not.toBeNull(); + }, +); diff --git a/apps/worker/test/schedule.test.ts b/apps/worker/test/schedule.test.ts new file mode 100644 index 00000000..a9088fc8 --- /dev/null +++ b/apps/worker/test/schedule.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { day, hour, minute, tournamentCadence } from "../src/schedule"; +import { retryDelay } from "../src/scheduler"; +import { TbaHttpError } from "../src/providers/tba"; + +const event = { + startDate: new Date("2026-03-05T00:00:00Z"), + endDate: new Date("2026-03-07T00:00:00Z"), + timezone: "America/Los_Angeles", +}; + +test("uses local event dates, including the last day after UTC midnight", () => { + expect(tournamentCadence(event, new Date("2026-03-08T03:00:00Z"))).toEqual({ + phase: "active", + matches: minute, + roster: hour, + }); + expect(tournamentCadence(event, new Date("2026-03-05T03:00:00Z"))).toEqual({ + phase: "upcoming", + matches: 30 * minute, + roster: 6 * hour, + }); + expect(tournamentCadence(event, new Date("2026-03-06T10:00:00Z"))).toEqual({ + phase: "active", + matches: 15 * minute, + roster: hour, + }); +}); + +test("backs off outside tournament days and tolerates missing timezone data", () => { + expect( + tournamentCadence(event, new Date("2026-02-01T12:00:00Z")).matches, + ).toBe(day); + expect( + tournamentCadence(event, new Date("2026-03-09T12:00:00Z")).matches, + ).toBe(6 * hour); + expect( + tournamentCadence(event, new Date("2026-04-01T12:00:00Z")).matches, + ).toBe(7 * day); + expect(tournamentCadence({ ...event, startDate: null }).phase).toBe( + "unknown", + ); + expect( + tournamentCadence( + { ...event, timezone: "invalid" }, + new Date("2026-03-06T12:00:00Z"), + ).phase, + ).toBe("active"); +}); + +test("retry delays honor exponential backoff and both forms of Retry-After", () => { + const now = new Date("2026-03-06T12:00:00Z"); + + expect(retryDelay(0, new Error())).toBe(minute); + expect(retryDelay(2, new Error())).toBe(4 * minute); + expect(retryDelay(20, new Error())).toBe(hour); + expect(retryDelay(0, new TbaHttpError("status", 429, "300"), now)).toBe( + 5 * minute, + ); + expect( + retryDelay( + 0, + new TbaHttpError("status", 503, "Fri, 06 Mar 2026 12:10:00 GMT"), + now, + ), + ).toBe(10 * minute); + expect(retryDelay(0, new TbaHttpError("status", 503, "invalid"), now)).toBe( + minute, + ); +}); diff --git a/apps/worker/test/scheduler.integration.test.ts b/apps/worker/test/scheduler.integration.test.ts new file mode 100644 index 00000000..b49a1449 --- /dev/null +++ b/apps/worker/test/scheduler.integration.test.ts @@ -0,0 +1,242 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { PrismaClient } from "@lovat/db"; +import { + claimJob, + enqueueImport, + reconcileSchedule, + runNextJob, +} from "../src/scheduler"; +import { day, minute } from "../src/schedule"; +import { TbaHttpError } from "../src/providers/tba"; + +const enabled = process.env.LOVAT_DB_TEST === "1"; +const fixture = crypto.randomUUID(); +const year = 2094; +const eventKey = `${fixture}-event`; +const kinds = [ + "teams", + "tournaments", + "team-seasons", + "districts", + "district-teams", + "tournament-teams", + "tournament-details", + "matches", +]; +const now = new Date("2094-03-06T18:00:00Z"); + +let db: PrismaClient; + +beforeAll(async () => { + if (!enabled) return; + + const url = new URL( + process.env.DATABASE_URL ?? "postgresql://localhost/missing", + ); + + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + url.pathname !== "/lovat_test" + ) + throw new Error("Use only a disposable local lovat_test database"); + + const { createDb } = await import("@lovat/db"); + db = createDb(url.href); + + await db.season.create({ data: { year } }); + await db.tournament.create({ + data: { + key: eventKey, + name: "Scheduler fixture", + seasonYear: year, + startDate: new Date("2094-03-05T00:00:00Z"), + endDate: new Date("2094-03-07T00:00:00Z"), + timezone: "America/Los_Angeles", + }, + }); +}); + +afterAll(async () => { + if (!db) return; + + try { + await db.importJob.deleteMany({ + where: { + OR: [ + { targetKey: { startsWith: fixture } }, + { targetKey: String(year) }, + { kind: "teams", targetKey: "global" }, + ], + }, + }); + await db.tournament.delete({ where: { key: eventKey } }); + await db.season.delete({ where: { year } }); + } finally { + await db.$disconnect(); + } +}); + +test.skipIf(!enabled)( + "reconciliation is idempotent, accelerates active jobs and preserves retry delays", + async () => { + await reconcileSchedule(db, now, year); + await reconcileSchedule(db, now, year); + + expect(await db.importJob.count({ where: { targetKey: eventKey } })).toBe( + 3, + ); + + await db.importJob.update({ + where: { kind_targetKey: { kind: "matches", targetKey: eventKey } }, + data: { runAt: new Date(now.getTime() + day) }, + }); + await reconcileSchedule(db, now, year); + + expect( + ( + await db.importJob.findUniqueOrThrow({ + where: { kind_targetKey: { kind: "matches", targetKey: eventKey } }, + }) + ).runAt.getTime(), + ).toBe(now.getTime() + minute); + + await db.importJob.update({ + where: { kind_targetKey: { kind: "matches", targetKey: eventKey } }, + data: { + attempts: 1, + lastError: "429", + runAt: new Date(now.getTime() + day), + }, + }); + await reconcileSchedule(db, now, year); + + expect( + ( + await db.importJob.findUniqueOrThrow({ + where: { kind_targetKey: { kind: "matches", targetKey: eventKey } }, + }) + ).runAt.getTime(), + ).toBe(now.getTime() + day); + + // Keep these recurring fixture jobs out of claims in the remaining tests. + await db.importJob.updateMany({ + where: { kind: { in: kinds } }, + data: { runAt: now }, + }); + }, +); + +test.skipIf(!enabled)( + "competing claims lease a due job once, then recover its expired lease", + async () => { + const targetKey = `${fixture}-claim`; + const job = await enqueueImport(db, "matches", targetKey); + const claims = await Promise.all([claimJob(db), claimJob(db)]); + + expect(claims.filter(Boolean)).toHaveLength(1); + const claimed = claims.find(Boolean)!; + expect(claimed.id).toBe(job.id); + + const other = claims.find((candidate) => candidate === null); + expect(other).toBeNull(); + + await db.importJob.update({ + where: { id: job.id }, + data: { leaseExpiresAt: new Date(0) }, + }); + const reclaimed = await claimJob(db); + + expect(reclaimed?.id).toBe(job.id); + expect(reclaimed?.leaseToken).not.toBe(claimed.leaseToken); + + await db.importJob.delete({ where: { id: job.id } }); + }, +); + +test.skipIf(!enabled)( + "successful jobs reschedule, failures honor Retry-After and stale completions are fenced", + async () => { + const job = await enqueueImport(db, "matches", eventKey); + await db.importJob.update({ + where: { id: job.id }, + data: { runAt: new Date(0), attempts: 2, lastError: "old" }, + }); + + await runNextJob(db, async () => {}); + + const completed = await db.importJob.findUniqueOrThrow({ + where: { id: job.id }, + }); + expect(completed.attempts).toBe(0); + expect(completed.lastError).toBeNull(); + expect(completed.leaseToken).toBeNull(); + expect(completed.runAt.getTime()).toBeGreaterThan(Date.now()); + + await db.importJob.update({ + where: { id: job.id }, + data: { runAt: new Date(0) }, + }); + const before = Date.now(); + + await runNextJob(db, async () => { + throw new TbaHttpError("matches", 429, "300"); + }); + + const failed = await db.importJob.findUniqueOrThrow({ + where: { id: job.id }, + }); + expect(failed.attempts).toBe(1); + expect(failed.lastError).toContain("429"); + expect(failed.runAt.getTime()).toBeGreaterThanOrEqual(before + 300_000); + + await db.importJob.update({ + where: { id: job.id }, + data: { runAt: new Date(0) }, + }); + + await runNextJob(db, async (claimed) => { + await db.importJob.update({ + where: { id: claimed.id }, + data: { + leaseToken: "replacement-token", + leaseExpiresAt: new Date(Date.now() + minute), + lastError: "replacement", + }, + }); + }); + + const replaced = await db.importJob.findUniqueOrThrow({ + where: { id: job.id }, + }); + expect(replaced.leaseToken).toBe("replacement-token"); + expect(replaced.lastError).toBe("replacement"); + }, +); + +test.skipIf(!enabled)( + "active matches take priority over an older background job", + async () => { + const today = new Date(new Date().toISOString().slice(0, 10)); + + await db.tournament.update({ + where: { key: eventKey }, + data: { startDate: today, endDate: today }, + }); + await db.importJob.update({ + where: { kind_targetKey: { kind: "matches", targetKey: eventKey } }, + data: { runAt: new Date(), leaseToken: null, leaseExpiresAt: null }, + }); + const background = await enqueueImport( + db, + "team-seasons", + "2095", + new Date(0), + ); + + try { + expect((await claimJob(db))?.targetKey).toBe(eventKey); + } finally { + await db.importJob.delete({ where: { id: background.id } }); + } + }, +); diff --git a/apps/worker/test/server.test.ts b/apps/worker/test/server.test.ts new file mode 100644 index 00000000..20f1d703 --- /dev/null +++ b/apps/worker/test/server.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { createHealthApp, healthPort } from "../src/server"; + +test("health requires a ready planner and a working database without exposing errors", async () => { + let ready = false; + let available = true; + let checks = 0; + + const app = createHealthApp({ + isReady: () => ready, + checkDatabase: async () => { + checks++; + if (!available) throw new Error("private connection details"); + }, + }); + + expect((await app.request("/health")).status).toBe(503); + expect(checks).toBe(0); + + ready = true; + expect((await app.request("/health")).status).toBe(200); + + available = false; + const failed = await app.request("/health"); + expect(failed.status).toBe(503); + expect(await failed.json()).toEqual({ status: "unavailable" }); +}); + +test("validates the health listener port", () => { + expect(healthPort("8080")).toBe(8080); + expect(() => healthPort("0")).toThrow("PORT"); + expect(() => healthPort("65536")).toThrow("PORT"); + expect(() => healthPort("invalid")).toThrow("PORT"); +}); diff --git a/apps/worker/test/tba.test.ts b/apps/worker/test/tba.test.ts new file mode 100644 index 00000000..b93c08b1 --- /dev/null +++ b/apps/worker/test/tba.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { createTbaClient, TbaHttpError } from "../src/providers/tba"; +import type { TbaMatch } from "../src/providers/tba"; + +function clientWith( + handler: ( + input: string | URL | Request, + init?: RequestInit, + ) => Promise, +) { + // Tests only use fetch's call signature, not Bun-specific static methods. + return createTbaClient({ + apiKey: "test-key", + fetcher: handler as typeof fetch, + }); +} + +describe("TBA requests", () => { + test("uses the season, district and roster endpoints with validated responses", async () => { + const paths: string[] = []; + const client = clientWith(async (input) => { + const path = new URL(String(input)).pathname; + paths.push(path); + + return Response.json( + path === "/api/v3/districts/2026" + ? [ + { + key: "2026fim", + year: 2026, + abbreviation: "fim", + display_name: "Michigan", + }, + ] + : [], + ); + }); + + const districts = await client.getDistricts(2026); + expect(districts.modified && districts.data[0]?.key).toBe("2026fim"); + + await client.getDistrictTeams("2026fim"); + await client.getTournamentTeams("2026casj"); + await client.getSeasonTeamsPage(2026, 0); + + expect(paths).toEqual([ + "/api/v3/districts/2026", + "/api/v3/district/2026fim/teams/simple", + "/api/v3/event/2026casj/teams/simple", + "/api/v3/teams/2026/0/simple", + ]); + expect(() => client.getDistricts(1991)).toThrow("year"); + expect(() => client.getSeasonTeamsPage(2026, -1)).toThrow("page"); + }); + + test("sends authentication and validators and validates status", async () => { + const client = clientWith(async (input, init) => { + expect(String(input)).toBe( + "https://www.thebluealliance.com/api/v3/status", + ); + const headers = new Headers(init?.headers); + expect(headers.get("X-TBA-Auth-Key")).toBe("test-key"); + expect(headers.get("If-None-Match")).toBe('"old"'); + expect(headers.get("If-Modified-Since")).toBe( + "Wed, 07 Oct 2026 00:00:00 GMT", + ); + expect(init?.redirect).toBe("manual"); + expect(init?.signal).toBeDefined(); + return Response.json( + { max_team_page: 25 }, + { headers: { etag: '"new"' } }, + ); + }); + expect( + await client.getStatus({ + etag: '"old"', + lastModified: "Wed, 07 Oct 2026 00:00:00 GMT", + }), + ).toEqual({ + modified: true, + data: { max_team_page: 25 }, + etag: '"new"', + lastModified: null, + }); + }); + + test("304 retains validators without attempting to parse a body", async () => { + const client = clientWith(async () => new Response(null, { status: 304 })); + expect(await client.getTeamsPage(0, { etag: '"old"' })).toEqual({ + modified: false, + etag: '"old"', + lastModified: null, + }); + await expect(client.getStatus()).rejects.toThrow( + "without cache validators", + ); + }); + + test("real Bun fetch accepts 304 and refuses to follow redirects", async () => { + let status = 304; + let redirectedRequests = 0; + const server = Bun.serve({ + port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/redirected") { + redirectedRequests++; + return Response.json({ max_team_page: 25 }); + } + + return new Response(null, { + status, + headers: status === 302 ? { location: "/redirected" } : {}, + }); + }, + }); + const client = clientWith((_input, init) => fetch(server.url, init)); + + try { + expect(await client.getStatus({ etag: '"cached"' })).toEqual({ + modified: false, + etag: '"cached"', + lastModified: null, + }); + + status = 302; + await expect(client.getStatus()).rejects.toMatchObject({ status: 302 }); + expect(redirectedRequests).toBe(0); + } finally { + await server.stop(true); + } + }); + + test("accepts empty pages and nullable metadata", async () => { + const client = clientWith(async (input) => + Response.json( + String(input).includes("/teams/0/") + ? [] + : [ + { + key: "frc254", + team_number: 254, + name: "Team name", + nickname: null, + city: null, + state_prov: null, + country: null, + }, + ], + ), + ); + const empty = await client.getTeamsPage(0); + expect(empty.modified && empty.data).toEqual([]); + const populated = await client.getTeamsPage(1); + expect(populated.modified && populated.data[0]?.nickname).toBeNull(); + expect(() => client.getTeamsPage(-1)).toThrow("nonnegative integer"); + }); + + test("rejects malformed responses", async () => { + const client = clientWith(async () => + Response.json({ max_team_page: "25" }), + ); + await expect(client.getStatus()).rejects.toThrow(); + }); + + test("validates full match responses and event metadata without remappings", async () => { + const alliance = { + team_keys: ["frc254"], + score: -1, + dq_team_keys: [], + surrogate_team_keys: [], + }; + + const match: TbaMatch = { + key: "2026casj_qm1", + event_key: "2026casj", + comp_level: "qm", + set_number: 1, + match_number: 1, + time: null, + predicted_time: null, + actual_time: null, + post_result_time: null, + winning_alliance: "", + alliances: { red: alliance, blue: alliance }, + score_breakdown: { red: null, blue: null }, + }; + + let malformed = false; + + const client = clientWith(async (input) => { + const path = new URL(String(input)).pathname; + + if (path === "/api/v3/event/2026casj") { + return Response.json({ key: "2026casj", playoff_type: 10 }); + } + + expect(path).toBe("/api/v3/event/2026casj/matches"); + + return Response.json([ + malformed ? { ...match, comp_level: "unknown" } : match, + ]); + }); + + const event = await client.getMatchEvent("2026casj"); + expect(event.modified && event.data.key).toBe("2026casj"); + + const fetched = await client.getMatches("2026casj"); + expect(fetched.modified && fetched.data[0]).toEqual(match); + + malformed = true; + await expect(client.getMatches("2026casj")).rejects.toThrow(); + }); + + test("exposes HTTP status and retry hint without leaking response bodies", async () => { + const client = clientWith( + async () => + new Response("private upstream message", { + status: 429, + headers: { "Retry-After": "60" }, + }), + ); + try { + await client.getStatus(); + throw new Error("Expected rejection"); + } catch (error) { + expect(error).toBeInstanceOf(TbaHttpError); + const httpError = error as TbaHttpError; + expect(httpError.status).toBe(429); + expect(httpError.retryAfter).toBe("60"); + expect(httpError.message).not.toContain("private upstream message"); + } + }); + + test("rejects endpoint traversal before making a request", async () => { + const client = clientWith(async () => { + throw new Error("Should not fetch"); + }); + await expect(client.get("../../outside", z.unknown())).rejects.toThrow( + "within /api/v3/", + ); + }); + + test("combines caller cancellation with the request timeout", async () => { + const controller = new AbortController(); + controller.abort(); + const client = clientWith(async (_input, init) => { + init?.signal?.throwIfAborted(); + return Response.json({ max_team_page: 0 }); + }); + await expect( + client.getStatus({ signal: controller.signal }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/worker/test/teams.integration.test.ts b/apps/worker/test/teams.integration.test.ts new file mode 100644 index 00000000..2c551eef --- /dev/null +++ b/apps/worker/test/teams.integration.test.ts @@ -0,0 +1,149 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { PrismaClient } from "@lovat/db"; +import { importAllTeams } from "../src/jobs/teams"; +import type { TbaTeam } from "../src/providers/tba"; + +const enabled = process.env.LOVAT_DB_TEST === "1"; +let db: PrismaClient; +const teamNumber = 1_000_000 + Math.floor(Math.random() * 1_000_000); +const resourceKeys = [0, 1, 2].map((page) => `teams/${page}/simple`); +const team = (number: number, nickname: string | null): TbaTeam => ({ + key: `frc${number}`, + team_number: number, + nickname, + name: "Official name", + city: null, + state_prov: null, + country: null, +}); + +beforeAll(async () => { + if (!enabled) return; + const url = new URL( + process.env.DATABASE_URL ?? "postgresql://localhost/missing", + ); + if ( + !["127.0.0.1", "localhost"].includes(url.hostname) || + url.pathname !== "/lovat_test" + ) { + throw new Error("Use only a disposable local lovat_test database"); + } + const { createDb } = await import("@lovat/db"); + db = createDb(url.href); +}); + +afterAll(async () => { + if (!db) return; + try { + await db.team.deleteMany({ + where: { number: { in: [teamNumber, teamNumber + 1] } }, + }); + await db.fetchState.deleteMany({ + where: { provider: "tba", resourceKey: { in: resourceKeys } }, + }); + } finally { + await db.$disconnect(); + } +}); + +test.skipIf(!enabled)( + "imports through the final page, crosses empty pages and reuses validators", + async () => { + const pages: number[] = []; + let cached = false; + const tba = { + async getStatus() { + return { + modified: true as const, + data: { max_team_page: 2 }, + etag: null, + lastModified: null, + }; + }, + async getTeamsPage(page: number, options?: { etag?: string | null }) { + pages.push(page); + if (cached) { + expect(options?.etag).toBe(`page-${page}`); + return { + modified: false as const, + etag: `page-${page}`, + lastModified: null, + }; + } + return { + modified: true as const, + etag: `page-${page}`, + lastModified: null, + data: + page === 0 + ? [] + : [team(teamNumber + page - 1, page === 1 ? "Nickname" : null)], + }; + }, + }; + await importAllTeams({ db, tba }); + expect(pages).toEqual([0, 1, 2]); + expect( + (await db.team.findUniqueOrThrow({ where: { number: teamNumber } })).name, + ).toBe("Nickname"); + expect( + (await db.team.findUniqueOrThrow({ where: { number: teamNumber + 1 } })) + .name, + ).toBe("Official name"); + cached = true; + pages.length = 0; + await importAllTeams({ db, tba }); + expect(pages).toEqual([0, 1, 2]); + }, +); + +test.skipIf(!enabled)( + "a failed page rolls back team changes and retains the previous ETag", + async () => { + await db.team.upsert({ + where: { number: teamNumber }, + create: { number: teamNumber, name: "Original" }, + update: { name: "Original" }, + }); + const identity = { provider: "tba", resourceKey: resourceKeys[0]! }; + await db.fetchState.upsert({ + where: { provider_resourceKey: identity }, + create: { ...identity, etag: "old" }, + update: { etag: "old" }, + }); + await expect( + importAllTeams({ + db, + tba: { + async getStatus() { + return { + modified: true, + data: { max_team_page: 0 }, + etag: null, + lastModified: null, + }; + }, + async getTeamsPage() { + // The second team exceeds PostgreSQL's Int range, after the first write. + return { + modified: true, + data: [team(teamNumber, "Changed"), team(2 ** 40, "Invalid")], + etag: "new", + lastModified: null, + }; + }, + }, + }), + ).rejects.toThrow(); + expect( + (await db.team.findUniqueOrThrow({ where: { number: teamNumber } })).name, + ).toBe("Original"); + expect( + ( + await db.fetchState.findUniqueOrThrow({ + where: { provider_resourceKey: identity }, + }) + ).etag, + ).toBe("old"); + }, +); diff --git a/apps/worker/test/tournament-details.integration.test.ts b/apps/worker/test/tournament-details.integration.test.ts new file mode 100644 index 00000000..8d9c580e --- /dev/null +++ b/apps/worker/test/tournament-details.integration.test.ts @@ -0,0 +1,214 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { PrismaClient } from "@lovat/db"; +import { importTournamentDetails } from "../src/jobs/tournament-details"; + +const enabled = process.env.LOVAT_DB_TEST === "1"; +const eventKey = `${crypto.randomUUID()}-details`; +const year = 2095; +const number = 7_000_000 + Math.floor(Math.random() * 100_000); +let db: PrismaClient; +const fresh = (data: T) => ({ + modified: true as const, + data, + etag: "details-v1", + lastModified: null, +}); +const unchanged = () => ({ + modified: false as const, + etag: "details-v1", + lastModified: null, +}); +const image = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII="; +const tba = { + getMatchEvent: async () => + fresh({ + key: eventKey, + playoff_type: 10, + remap_teams: { [`frc${number}`]: "alias" }, + }), + getSelections: async () => + fresh([ + { picks: ["alias"], backup: { in: `frc${number + 1}`, out: "alias" } }, + ]), + getAwards: async () => + fresh([ + { + event_key: eventKey, + award_type: 1, + name: "Winner", + recipient_list: [{ team_key: "alias", awardee: null }], + }, + ]), + getTeamMedia: async () => + fresh([ + { type: "avatar", preferred: true, details: { base64Image: image } }, + ]), +}; + +beforeAll(async () => { + if (!enabled) return; + + const url = new URL(process.env.DATABASE_URL!); + + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + url.pathname !== "/lovat_test" + ) + throw new Error("Use a disposable local lovat_test database"); + + const { createDb } = await import("@lovat/db"); + db = createDb(url.href); + await db.season.upsert({ where: { year }, update: {}, create: { year } }); + await db.team.create({ data: { number, name: "Fixture" } }); + await db.teamSeason.create({ + data: { teamNumber: number, seasonYear: year, name: "Fixture" }, + }); + await db.tournament.create({ + data: { + key: eventKey, + name: "Fixture", + seasonYear: year, + teams: { create: { teamNumber: number } }, + }, + }); +}); + +afterAll(async () => { + if (!db) return; + + await db.tournament.delete({ where: { key: eventKey } }); + await db.teamSeason.deleteMany({ where: { teamNumber: number } }); + await db.team.delete({ where: { number } }); + await db.fetchState.deleteMany({ + where: { + provider: "tba", + OR: [ + { resourceKey: { contains: eventKey } }, + { resourceKey: `team/frc${number}/media/${year}` }, + ], + }, + }); + await db.season.deleteMany({ + where: { year, tournaments: { none: {} }, teamSeasons: { none: {} } }, + }); + await db.$disconnect(); +}); + +test.skipIf(!enabled)( + "imports selections, remapped awards, backups and avatars; cached responses preserve them", + async () => { + await importTournamentDetails(eventKey, { db, tba }); + + const event = await db.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + }); + expect(event.allianceSelections).toEqual([ + { teams: [number], backup: { in: number + 1, out: number } }, + ]); + expect(event.awards).toEqual([ + { + type: 1, + name: "Winner", + recipients: [{ teamNumber: number, name: null }], + }, + ]); + expect( + ( + await db.teamSeason.findUniqueOrThrow({ + where: { + teamNumber_seasonYear: { teamNumber: number, seasonYear: year }, + }, + }) + ).avatar, + ).toBe(`data:image/png;base64,${image}`); + + await importTournamentDetails(eventKey, { + db, + tba: { + ...tba, + getSelections: async () => unchanged(), + getAwards: async () => unchanged(), + getTeamMedia: async () => unchanged(), + }, + }); + expect( + (await db.tournament.findUniqueOrThrow({ where: { key: eventKey } })) + .allianceSelections, + ).toEqual(event.allianceSelections); + }, +); + +test.skipIf(!enabled)( + "rolls back selections and cache validators when award validation fails", + async () => { + const before = await db.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + }); + + await expect( + importTournamentDetails(eventKey, { + db, + tba: { + ...tba, + getSelections: async () => ({ ...fresh([]), etag: "bad-v2" }), + getAwards: async () => + fresh([ + { + event_key: "wrong-event", + award_type: 1, + name: "Wrong", + recipient_list: [], + }, + ]), + }, + }), + ).rejects.toThrow("Award event mismatch"); + expect( + (await db.tournament.findUniqueOrThrow({ where: { key: eventKey } })) + .allianceSelections, + ).toEqual(before.allianceSelections); + expect( + ( + await db.fetchState.findUniqueOrThrow({ + where: { + provider_resourceKey: { + provider: "tba", + resourceKey: `event/${eventKey}/alliances`, + }, + }, + }) + ).etag, + ).toBe("details-v1"); + }, +); + +test.skipIf(!enabled)( + "clears withdrawn selections, awards and avatars on fresh empty responses", + async () => { + await importTournamentDetails(eventKey, { + db, + tba: { + ...tba, + getSelections: async () => fresh([]), + getAwards: async () => fresh([]), + getTeamMedia: async () => fresh([]), + }, + }); + + const event = await db.tournament.findUniqueOrThrow({ + where: { key: eventKey }, + }); + expect(event.allianceSelections).toEqual([]); + expect(event.awards).toEqual([]); + expect( + ( + await db.teamSeason.findUniqueOrThrow({ + where: { + teamNumber_seasonYear: { teamNumber: number, seasonYear: year }, + }, + }) + ).avatar, + ).toBeNull(); + }, +); diff --git a/apps/worker/test/tournaments.integration.test.ts b/apps/worker/test/tournaments.integration.test.ts new file mode 100644 index 00000000..b1744c5f --- /dev/null +++ b/apps/worker/test/tournaments.integration.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { PrismaClient } from "@lovat/db"; +import type { TbaTournament } from "../src/providers/tba"; +import { importTournaments } from "../src/jobs/tournaments"; + +const enabled = process.env.LOVAT_DB_TEST === "1"; +const fixture = crypto.randomUUID(); +const years = [2090, 2091, 2092]; + +let db: PrismaClient; + +const tournament = (key: string, year: number): TbaTournament => ({ + key, + year, + name: "Tournament fixture", + city: "San Jose", + start_date: `${year}-03-01`, + end_date: `${year}-03-03`, + timezone: "America/Los_Angeles", + event_type: 0, + playoff_type: 10, + week: 2, + parent_event_key: null, + district: null, +}); + +beforeAll(async () => { + if (!enabled) return; + + const url = new URL( + process.env.DATABASE_URL ?? "postgresql://localhost/missing", + ); + + if ( + !["localhost", "127.0.0.1"].includes(url.hostname) || + url.pathname !== "/lovat_test" + ) { + throw new Error("Use only a disposable local lovat_test database"); + } + + const { createDb } = await import("@lovat/db"); + + db = createDb(url.href); +}); + +afterAll(async () => { + if (!db) return; + + try { + await db.tournament.deleteMany({ where: { key: { startsWith: fixture } } }); + await db.districtSeason.deleteMany({ + where: { key: { startsWith: fixture } }, + }); + await db.season.deleteMany({ + where: { + year: { in: years }, + tournaments: { none: {} }, + teamSeasons: { none: {} }, + districtSeasons: { none: {} }, + }, + }); + await db.fetchState.deleteMany({ + where: { + provider: "tba", + resourceKey: { + in: years.map((year) => `events/${year}?schema=weeks-v1`), + }, + }, + }); + } finally { + await db.$disconnect(); + } +}); + +test.skipIf(!enabled)( + "imports inclusive years, district parents, dates and child-first event lists", + async () => { + const calls: number[] = []; + let cached = false; + + const parent = tournament(`${fixture}-parent`, 2091); + + const child = { + ...tournament(`${fixture}-child`, 2091), + parent_event_key: parent.key, + district: { + key: `${fixture}-district`, + year: 2091, + abbreviation: fixture, + display_name: "District fixture", + }, + }; + + const tba = { + async getTournaments(year: number, options?: { etag?: string | null }) { + calls.push(year); + + if (cached) { + expect(options?.etag).toBe(`year-${year}`); + + return { + modified: false as const, + etag: `year-${year}`, + lastModified: null, + }; + } + + return { + modified: true as const, + data: year === 2090 ? [] : [child, parent], + etag: `year-${year}`, + lastModified: null, + }; + }, + }; + + await importTournaments(2090, 2091, { db, tba }); + + expect(calls).toEqual([2090, 2091]); + + const saved = await db.tournament.findUniqueOrThrow({ + where: { key: child.key }, + include: { district: true }, + }); + + expect(saved.parentTournamentKey).toBe(parent.key); + expect(saved.district?.name).toBe("District fixture"); + expect(saved.startDate?.toISOString()).toBe("2091-03-01T00:00:00.000Z"); + expect(saved.date).toBe("2091-03-01"); + expect(saved.timezone).toBe("America/Los_Angeles"); + expect(saved.week).toBe(2); + + await db.tournament.update({ + where: { key: child.key }, + data: { latestFetchETag: "match-etag" }, + }); + + cached = true; + calls.length = 0; + + await importTournaments(2090, 2091, { db, tba }); + + expect(calls).toEqual([2090, 2091]); + expect( + await db.tournament.count({ where: { key: { startsWith: fixture } } }), + ).toBe(2); + expect( + (await db.tournament.findUniqueOrThrow({ where: { key: child.key } })) + .latestFetchETag, + ).toBe("match-etag"); + }, +); + +test.skipIf(!enabled)( + "missing parents roll back the season, tournaments and cache update", + async () => { + const identity = { + provider: "tba", + resourceKey: "events/2092?schema=weeks-v1", + }; + + await db.fetchState.upsert({ + where: { provider_resourceKey: identity }, + create: { ...identity, etag: "old" }, + update: { etag: "old" }, + }); + + await expect( + importTournaments(2092, 2092, { + db, + tba: { + async getTournaments() { + return { + modified: true, + data: [ + { + ...tournament(`${fixture}-failed`, 2092), + parent_event_key: `${fixture}-missing`, + }, + ], + etag: "new", + lastModified: null, + }; + }, + }, + }), + ).rejects.toThrow(); + + expect( + await db.tournament.count({ where: { key: `${fixture}-failed` } }), + ).toBe(0); + expect(await db.season.findUnique({ where: { year: 2092 } })).toBeNull(); + expect( + ( + await db.fetchState.findUniqueOrThrow({ + where: { provider_resourceKey: identity }, + }) + ).etag, + ).toBe("old"); + }, +); + +test("rejects invalid year ranges before loading clients", async () => { + await expect(importTournaments(2026, 2025)).rejects.toThrow("ascending"); + await expect(importTournaments(1991, 2026)).rejects.toThrow("1992"); +}); diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 00000000..b2e7497d --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/docs/frc-schema-worker-rollout.md b/docs/frc-schema-worker-rollout.md new file mode 100644 index 00000000..62ae4021 --- /dev/null +++ b/docs/frc-schema-worker-rollout.md @@ -0,0 +1,60 @@ +# Competition schema and import worker rollout + +The schema/worker PR is the prerequisite for the public website PR (#82). Merge +this infrastructure first. The website PR includes public competition queries, +scouting aggregates, and presentation; none of those routes are required to run +the importer. + +## Before merging + +1. Check the schema/worker PR's Server, Worker, database integration, Docker, + CodeQL, and Monorepo CI results. +2. Back up the production database and verify its migration history. This PR adds + five forward migrations after `0_baseline`. A populated database must already + have a verified baseline recorded; do not apply the baseline SQL to existing + tables or use `db push` to bypass migration history. +3. Rehearse migration deployment on a production-schema/data clone. Compare + scouting report/action counts and keys before and after, run migration status, + and check schema drift. Disposable fixture tests and a sanitized PR database + do not replace this production rehearsal. +4. Configure the production worker as an empty service before the API rollout: + repository root `/`, config `/apps/worker/railway.json`, Dockerfile + `apps/worker/Dockerfile`, one replica, sleeping disabled, no cron. Set + `DATABASE_URL=${{Postgres.DATABASE_PRIVATE_URL}}`, `TBA_KEY=${{api.TBA_KEY}}`, + and `WORKER_CONCURRENCY=2`. Verify names and references in production. Do not + connect its source/start it until migrations finish. + +## Deploy in order + +1. Merge the schema/worker PR and deploy the API. Its existing start command runs + `npm --prefix ../../packages/db run db:deploy` against `DATABASE_PRIVATE_URL` + before starting. Confirm the production service uses repository-root builds + and `/apps/server/railway.json`, and has no inherited `db push` command. Use a + single migration owner; avoid simultaneous migration runners. +2. Confirm all five migrations are applied and `/status` is healthy. Existing + reports and participant keys are retained; missing parent teams receive a + number-only placeholder. +3. Connect the worker to `main`, build, and verify `/health`. The worker deliberately + does not run migrations. Check imports succeed and claimed jobs complete; + participant changes affecting scouted slots must fail for reconciliation. +4. Smoke-test Dashboard's match refresh. The API now queues imports and returns + stored data immediately, so the worker must be running for refreshed matches + to appear. This asynchronous behavior is the main operational change. +5. Optionally queue historical imports with `bun run backfill 1992 2025` from a + worker image using the verified production connection. Current-season imports + start automatically; do not copy sanitized preview accounts into production. +6. Retarget #82 to `main` after this prerequisite is merged, then review/deploy + website and public API changes separately. If the first PR is squash-merged, + update #82's ancestry so the infrastructure does not reappear in its diff. + +## Recovery and preview data + +If the worker fails, stop it and resolve the job's error; queue rows, validators, +and leases persist in PostgreSQL. Do not reverse migrations that have received +new data. The old API importer must not run concurrently with the worker. + +Railway PR environments are ephemeral and may be deleted when a PR closes. +Duplicating environment configuration does not guarantee a copy of its volume +contents. Keep a verified database snapshot outside the environment, retain #82 +while testing the website, and verify any restored preview before relying on it. +The original September backup and the current PR database must remain untouched. diff --git a/packages/db/README.md b/packages/db/README.md index 2d444135..f1f26b4d 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -66,6 +66,45 @@ Railway must use repository root `/` and `apps/server/Dockerfile` so both direct ## Integration checks +### Competition-data migration + +`20261010193000_add_import_fetch_state_and_jobs` adds minimal `FetchState` +and `ImportJob` tables. Fetch state is keyed by provider and endpoint (including +meaningful query parameters). Workers must commit new HTTP validators with the +imported data, not before it. Jobs are unique by kind and target; successful jobs +can be deleted or rescheduled by the worker. Claiming, leases, retries, +and scheduling are worker responsibilities, not implemented by this migration. +Existing tournament ETags are retained until ingestion moves to endpoint state. + +`20261010220000_add_tournament_gaps` adds worker-inferred pauses with lunch, +overnight (EOD/new-day), playoff-transition, delay, and generic-break types. +Each row links its preceding and following canonical matches and records +whether inference used actual, scheduled, or predicted times. Interval +boundaries are estimates, not official announcements or exact match end times. + +The additive migration preserves existing reports and match data. Composite +foreign keys enforce that both boundary matches belong to the gap's tournament. +SQL CHECK constraints enforce positive intervals and distinct boundary matches; +preserve these constraints in later migrations. Gaps cascade when their +tournament or boundary matches are deleted. Existing tournaments acquire gaps +on the next match refresh, including a cached response, or through the worker's +explicit gap backfill. + +`20261010180215_add_season_match_and_more` adds seasons, districts, rosters, +canonical matches, and alliance results without changing existing report keys +or measurements. It runs in one transaction and creates missing `Team` parents +for existing scouting slots before adding the team foreign key. Those parents +use an explicit `Team ` fallback name; official team ingestion can +replace it later. Existing team names are preserved. Canonical match links and +other imported metadata remain null until a verified backfill or import. + +The migration enforces stations 1–3 with a SQL CHECK constraint that Prisma +cannot express. Keep that constraint in future migrations. Test coverage checks +populated current-season report/action preservation, missing-team backfill, +station and team constraints, and atomic rollback. Verify migration history on +a staging clone before production deployment; these local checks do not prove +that a particular deployed database is ready to migrate. + Use only a disposable local database named `lovat_test`: ```bash @@ -79,3 +118,15 @@ LOVAT_DB_TEST=1 npm run test:integration ``` CI provisions PostgreSQL 16, replays the complete migration history, checks repeat deployment and schema drift, and exercises account/filter preservation and legacy-data rollback in additional disposable databases. Tests exercise actual writes, nested relations, generated enums, JSON defaults and updates, Prisma error identity, transaction rollback, parameterized SQL, and report/event commit and cascade behavior. They do not certify production migration readiness. Server build, compilation checks, and lint remain separate required checks. + +Tournament presentation snapshots use `Tournament.allianceSelections` and +`Tournament.awards` (validated JSON) rather than extra recipient/pick tables. +Selections contain ordered numeric teams and an optional backup in/out pair; +awards contain their provider type, name, and numeric team/person recipients. +`TeamSeason.avatar` stores a raster image data URI from that season's TBA media endpoint. +These fields contain public official data and preserve the season context. + +`Tournament.week` stores TBA's zero-based regular-season week index. Public views +display it as Week 1, Week 2, and so on; championship grouping uses `eventType`. +The worker uses a versioned events cache key to refresh existing season records +after this field is introduced. diff --git a/packages/db/prisma/migrations/20261010180215_add_season_match_and_more/migration.sql b/packages/db/prisma/migrations/20261010180215_add_season_match_and_more/migration.sql new file mode 100644 index 00000000..58a2304d --- /dev/null +++ b/packages/db/prisma/migrations/20261010180215_add_season_match_and_more/migration.sql @@ -0,0 +1,186 @@ +-- Apply schema changes and the parent-team backfill atomically. +BEGIN; + +-- Older scouting slots did not require a Team row. Preserve those slots and +-- their reports using an explicit number-only fallback, not invented metadata. +-- A later official team import replaces the fallback name. +INSERT INTO "Team" ("number", "name") +SELECT DISTINCT slots."teamNumber", 'Team ' || slots."teamNumber"::text +FROM "TeamMatchData" AS slots +WHERE NOT EXISTS ( + SELECT 1 FROM "Team" AS teams WHERE teams."number" = slots."teamNumber" +) +ON CONFLICT ("number") DO NOTHING; +-- CreateEnum +CREATE TYPE "AllianceColor" AS ENUM ('RED', 'BLUE'); + +-- CreateEnum +CREATE TYPE "CompetitionLevel" AS ENUM ('QUALIFICATION', 'EIGHTHFINAL', 'QUARTERFINAL', 'SEMIFINAL', 'FINAL'); + +-- CreateEnum +CREATE TYPE "MatchStatus" AS ENUM ('SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELED'); + +-- AlterTable +ALTER TABLE "TeamMatchData" ADD COLUMN "alliance" "AllianceColor", +ADD COLUMN "disqualified" BOOLEAN, +ADD COLUMN "externalParticipantKey" TEXT, +ADD COLUMN "matchKey" TEXT, +ADD COLUMN "station" INTEGER, +ADD COLUMN "surrogate" BOOLEAN; + +-- Prisma cannot express CHECK constraints; keep this rule in migration SQL. +ALTER TABLE "TeamMatchData" ADD CONSTRAINT "TeamMatchData_station_check" +CHECK ("station" IS NULL OR "station" BETWEEN 1 AND 3); + +-- AlterTable +ALTER TABLE "Tournament" ADD COLUMN "districtSeasonKey" TEXT, +ADD COLUMN "endDate" DATE, +ADD COLUMN "eventType" INTEGER, +ADD COLUMN "officialDataRevision" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "officialDataUpdatedAt" TIMESTAMP(3), +ADD COLUMN "parentTournamentKey" TEXT, +ADD COLUMN "playoffType" INTEGER, +ADD COLUMN "seasonYear" INTEGER, +ADD COLUMN "startDate" DATE, +ADD COLUMN "timezone" TEXT; + +-- CreateTable +CREATE TABLE "Season" ( + "year" INTEGER NOT NULL, + "gameName" TEXT, + + CONSTRAINT "Season_pkey" PRIMARY KEY ("year") +); + +-- CreateTable +CREATE TABLE "DistrictSeason" ( + "key" TEXT NOT NULL, + "seasonYear" INTEGER NOT NULL, + "abbreviation" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "DistrictSeason_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "TeamSeason" ( + "teamNumber" INTEGER NOT NULL, + "seasonYear" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "city" TEXT, + "stateProvince" TEXT, + "country" TEXT, + "districtSeasonKey" TEXT, + + CONSTRAINT "TeamSeason_pkey" PRIMARY KEY ("teamNumber","seasonYear") +); + +-- CreateTable +CREATE TABLE "TeamTournament" ( + "teamNumber" INTEGER NOT NULL, + "tournamentKey" TEXT NOT NULL, + + CONSTRAINT "TeamTournament_pkey" PRIMARY KEY ("teamNumber","tournamentKey") +); + +-- CreateTable +CREATE TABLE "Match" ( + "key" TEXT NOT NULL, + "tournamentKey" TEXT NOT NULL, + "competitionLevel" "CompetitionLevel" NOT NULL, + "setNumber" INTEGER NOT NULL, + "matchNumber" INTEGER NOT NULL, + "displayOrder" INTEGER, + "scheduledTime" TIMESTAMP(3), + "predictedTime" TIMESTAMP(3), + "actualTime" TIMESTAMP(3), + "postResultTime" TIMESTAMP(3), + "status" "MatchStatus" NOT NULL DEFAULT 'SCHEDULED', + "winningAlliance" "AllianceColor", + + CONSTRAINT "Match_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "MatchAlliance" ( + "matchKey" TEXT NOT NULL, + "color" "AllianceColor" NOT NULL, + "score" INTEGER, + "scoreBreakdown" JSONB, + + CONSTRAINT "MatchAlliance_pkey" PRIMARY KEY ("matchKey","color") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DistrictSeason_seasonYear_abbreviation_key" ON "DistrictSeason"("seasonYear", "abbreviation"); + +-- CreateIndex +CREATE INDEX "TeamSeason_districtSeasonKey_idx" ON "TeamSeason"("districtSeasonKey"); + +-- CreateIndex +CREATE INDEX "TeamTournament_tournamentKey_idx" ON "TeamTournament"("tournamentKey"); + +-- CreateIndex +CREATE INDEX "Match_tournamentKey_displayOrder_idx" ON "Match"("tournamentKey", "displayOrder"); + +-- CreateIndex +CREATE INDEX "Match_tournamentKey_scheduledTime_idx" ON "Match"("tournamentKey", "scheduledTime"); + +-- CreateIndex +CREATE UNIQUE INDEX "Match_tournamentKey_competitionLevel_setNumber_matchNumber_key" ON "Match"("tournamentKey", "competitionLevel", "setNumber", "matchNumber"); + +-- CreateIndex +CREATE INDEX "TeamMatchData_matchKey_teamNumber_idx" ON "TeamMatchData"("matchKey", "teamNumber"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMatchData_matchKey_alliance_station_key" ON "TeamMatchData"("matchKey", "alliance", "station"); + +-- CreateIndex +CREATE INDEX "Tournament_seasonYear_startDate_idx" ON "Tournament"("seasonYear", "startDate"); + +-- CreateIndex +CREATE INDEX "Tournament_districtSeasonKey_idx" ON "Tournament"("districtSeasonKey"); + +-- CreateIndex +CREATE INDEX "Tournament_parentTournamentKey_idx" ON "Tournament"("parentTournamentKey"); + +-- AddForeignKey +ALTER TABLE "DistrictSeason" ADD CONSTRAINT "DistrictSeason_seasonYear_fkey" FOREIGN KEY ("seasonYear") REFERENCES "Season"("year") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamSeason" ADD CONSTRAINT "TeamSeason_teamNumber_fkey" FOREIGN KEY ("teamNumber") REFERENCES "Team"("number") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamSeason" ADD CONSTRAINT "TeamSeason_seasonYear_fkey" FOREIGN KEY ("seasonYear") REFERENCES "Season"("year") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamSeason" ADD CONSTRAINT "TeamSeason_districtSeasonKey_fkey" FOREIGN KEY ("districtSeasonKey") REFERENCES "DistrictSeason"("key") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tournament" ADD CONSTRAINT "Tournament_seasonYear_fkey" FOREIGN KEY ("seasonYear") REFERENCES "Season"("year") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tournament" ADD CONSTRAINT "Tournament_districtSeasonKey_fkey" FOREIGN KEY ("districtSeasonKey") REFERENCES "DistrictSeason"("key") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tournament" ADD CONSTRAINT "Tournament_parentTournamentKey_fkey" FOREIGN KEY ("parentTournamentKey") REFERENCES "Tournament"("key") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamTournament" ADD CONSTRAINT "TeamTournament_teamNumber_fkey" FOREIGN KEY ("teamNumber") REFERENCES "Team"("number") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamTournament" ADD CONSTRAINT "TeamTournament_tournamentKey_fkey" FOREIGN KEY ("tournamentKey") REFERENCES "Tournament"("key") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Match" ADD CONSTRAINT "Match_tournamentKey_fkey" FOREIGN KEY ("tournamentKey") REFERENCES "Tournament"("key") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MatchAlliance" ADD CONSTRAINT "MatchAlliance_matchKey_fkey" FOREIGN KEY ("matchKey") REFERENCES "Match"("key") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMatchData" ADD CONSTRAINT "TeamMatchData_teamNumber_fkey" FOREIGN KEY ("teamNumber") REFERENCES "Team"("number") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMatchData" ADD CONSTRAINT "TeamMatchData_matchKey_fkey" FOREIGN KEY ("matchKey") REFERENCES "Match"("key") ON DELETE SET NULL ON UPDATE CASCADE; + +COMMIT; diff --git a/packages/db/prisma/migrations/20261010193000_add_import_fetch_state_and_jobs/migration.sql b/packages/db/prisma/migrations/20261010193000_add_import_fetch_state_and_jobs/migration.sql new file mode 100644 index 00000000..5219c22d --- /dev/null +++ b/packages/db/prisma/migrations/20261010193000_add_import_fetch_state_and_jobs/migration.sql @@ -0,0 +1,28 @@ +BEGIN; + +CREATE TABLE "FetchState" ( + "provider" TEXT NOT NULL, + "resourceKey" TEXT NOT NULL, + "etag" TEXT, + "lastModified" TEXT, + + CONSTRAINT "FetchState_pkey" PRIMARY KEY ("provider", "resourceKey") +); + +CREATE TABLE "ImportJob" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "targetKey" TEXT NOT NULL, + "runAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "attempts" INTEGER NOT NULL DEFAULT 0, + "leaseToken" TEXT, + "leaseExpiresAt" TIMESTAMP(3), + "lastError" TEXT, + + CONSTRAINT "ImportJob_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ImportJob_kind_targetKey_key" ON "ImportJob"("kind", "targetKey"); +CREATE INDEX "ImportJob_runAt_idx" ON "ImportJob"("runAt"); + +COMMIT; diff --git a/packages/db/prisma/migrations/20261010220000_add_tournament_gaps/migration.sql b/packages/db/prisma/migrations/20261010220000_add_tournament_gaps/migration.sql new file mode 100644 index 00000000..3b54c5a8 --- /dev/null +++ b/packages/db/prisma/migrations/20261010220000_add_tournament_gaps/migration.sql @@ -0,0 +1,27 @@ +BEGIN; + +CREATE TYPE "TournamentGapType" AS ENUM ('LUNCH', 'OVERNIGHT', 'PLAYOFF_TRANSITION', 'DELAY', 'BREAK'); +CREATE TYPE "TournamentGapTimingSource" AS ENUM ('ACTUAL', 'SCHEDULED', 'PREDICTED'); + +CREATE UNIQUE INDEX "Match_key_tournamentKey_key" ON "Match"("key", "tournamentKey"); + +CREATE TABLE "TournamentGap" ( + "tournamentKey" TEXT NOT NULL, + "afterMatchKey" TEXT NOT NULL, + "beforeMatchKey" TEXT NOT NULL, + "type" "TournamentGapType" NOT NULL, + "timingSource" "TournamentGapTimingSource" NOT NULL, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TournamentGap_pkey" PRIMARY KEY ("afterMatchKey", "beforeMatchKey"), + CONSTRAINT "TournamentGap_positive_interval" CHECK ("endTime" > "startTime"), + CONSTRAINT "TournamentGap_distinct_matches" CHECK ("afterMatchKey" <> "beforeMatchKey"), + CONSTRAINT "TournamentGap_tournamentKey_fkey" FOREIGN KEY ("tournamentKey") REFERENCES "Tournament"("key") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TournamentGap_afterMatchKey_tournamentKey_fkey" FOREIGN KEY ("afterMatchKey", "tournamentKey") REFERENCES "Match"("key", "tournamentKey") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TournamentGap_beforeMatchKey_tournamentKey_fkey" FOREIGN KEY ("beforeMatchKey", "tournamentKey") REFERENCES "Match"("key", "tournamentKey") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "TournamentGap_tournamentKey_startTime_idx" ON "TournamentGap"("tournamentKey", "startTime"); + +COMMIT; diff --git a/packages/db/prisma/migrations/20261011000100_add_tournament_presentation/migration.sql b/packages/db/prisma/migrations/20261011000100_add_tournament_presentation/migration.sql new file mode 100644 index 00000000..accd2f2c --- /dev/null +++ b/packages/db/prisma/migrations/20261011000100_add_tournament_presentation/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Tournament" ADD COLUMN "allianceSelections" JSONB, ADD COLUMN "awards" JSONB; +ALTER TABLE "TeamSeason" ADD COLUMN "avatar" TEXT; diff --git a/packages/db/prisma/migrations/20261011000200_add_event_week/migration.sql b/packages/db/prisma/migrations/20261011000200_add_event_week/migration.sql new file mode 100644 index 00000000..b4340198 --- /dev/null +++ b/packages/db/prisma/migrations/20261011000200_add_event_week/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Tournament" ADD COLUMN "week" INTEGER; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/packages/db/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index af983266..0dd2e24a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1,7 +1,7 @@ generator client { - provider = "prisma-client" - output = "../src/generated/prisma" - moduleFormat = "esm" + provider = "prisma-client" + output = "../src/generated/prisma" + moduleFormat = "esm" importFileExtension = "js" } @@ -27,16 +27,258 @@ model FeatureToggle { enabled Boolean @default(true) } +model FetchState { + provider String + resourceKey String // Endpoint path plus meaningful query parameters. + etag String? + lastModified String? + + @@id([provider, resourceKey]) +} + +model ImportJob { + id String @id @default(uuid()) + kind String + targetKey String + runAt DateTime @default(now()) + + attempts Int @default(0) + leaseToken String? + leaseExpiresAt DateTime? + lastError String? + + @@unique([kind, targetKey]) + @@index([runAt]) +} + +model Season { + year Int @id + gameName String? + + teamSeasons TeamSeason[] + districtSeasons DistrictSeason[] + tournaments Tournament[] +} + +model DistrictSeason { + key String @id + seasonYear Int + abbreviation String + name String + + season Season @relation(fields: [seasonYear], references: [year]) + teamSeasons TeamSeason[] + tournaments Tournament[] + + @@unique([seasonYear, abbreviation]) +} + +model Team { + number Int @id + name String + + seasons TeamSeason[] + tournamentTeams TeamTournament[] + matchData TeamMatchData[] + + registeredTeam RegisteredTeam? +} + +model TeamSeason { + teamNumber Int + seasonYear Int + name String + city String? + stateProvince String? + country String? + districtSeasonKey String? + avatar String? // Raster avatar data URI from TBA season media. + + team Team @relation(fields: [teamNumber], references: [number]) + season Season @relation(fields: [seasonYear], references: [year]) + district DistrictSeason? @relation(fields: [districtSeasonKey], references: [key]) + + @@id([teamNumber, seasonYear]) + @@index([districtSeasonKey]) +} + +model Tournament { + key String @id + name String + + location String? + date String? + latestFetchETag String? + + seasonYear Int? + districtSeasonKey String? + parentTournamentKey String? + + startDate DateTime? @db.Date + endDate DateTime? @db.Date + timezone String? + + eventType Int? + playoffType Int? + week Int? // Provider week index; null outside the regular season. + + // Validated official snapshots; selection order and backup participants are preserved. + allianceSelections Json? + awards Json? + + officialDataUpdatedAt DateTime? + officialDataRevision Int @default(0) + + season Season? @relation(fields: [seasonYear], references: [year]) + district DistrictSeason? @relation(fields: [districtSeasonKey], references: [key]) + + parentTournament Tournament? @relation("TournamentHierarchy", fields: [parentTournamentKey], references: [key]) + childTournaments Tournament[] @relation("TournamentHierarchy") + + teams TeamTournament[] + matches Match[] + teamMatchData TeamMatchData[] + gaps TournamentGap[] + + mutablePicklists MutablePicklist[] + scouterScheduleShifts ScouterScheduleShift[] + + @@index([seasonYear, startDate]) + @@index([districtSeasonKey]) + @@index([parentTournamentKey]) +} + +model TeamTournament { + teamNumber Int + tournamentKey String + + team Team @relation(fields: [teamNumber], references: [number]) + tournament Tournament @relation(fields: [tournamentKey], references: [key], onDelete: Cascade) + + @@id([teamNumber, tournamentKey]) + @@index([tournamentKey]) +} + +model Match { + key String @id // Canonical key, e.g. "2026..._sf1m1" + tournamentKey String + + competitionLevel CompetitionLevel + setNumber Int + matchNumber Int + displayOrder Int? + + scheduledTime DateTime? + predictedTime DateTime? + actualTime DateTime? + postResultTime DateTime? + + status MatchStatus @default(SCHEDULED) + winningAlliance AllianceColor? // Null for ties or unknown results. + + tournament Tournament @relation(fields: [tournamentKey], references: [key], onDelete: Cascade) + + alliances MatchAlliance[] + teamSlots TeamMatchData[] + gapsAfter TournamentGap[] @relation("GapAfterMatch") + gapsBefore TournamentGap[] @relation("GapBeforeMatch") + + @@unique([tournamentKey, competitionLevel, setNumber, matchNumber]) + @@unique([key, tournamentKey]) // Composite foreign keys keep gap boundaries in this event. + @@index([tournamentKey, displayOrder]) + @@index([tournamentKey, scheduledTime]) +} + +model MatchAlliance { + matchKey String + color AllianceColor + + score Int? + scoreBreakdown Json? + + match Match @relation(fields: [matchKey], references: [key], onDelete: Cascade) + + @@id([matchKey, color]) +} + +// Worker-derived intervals; classifications and boundaries are inferred. +model TournamentGap { + tournamentKey String + afterMatchKey String + beforeMatchKey String + + type TournamentGapType + timingSource TournamentGapTimingSource + startTime DateTime + endTime DateTime + + tournament Tournament @relation(fields: [tournamentKey], references: [key], onDelete: Cascade) + afterMatch Match @relation("GapAfterMatch", fields: [afterMatchKey, tournamentKey], references: [key, tournamentKey], onDelete: Cascade) + beforeMatch Match @relation("GapBeforeMatch", fields: [beforeMatchKey, tournamentKey], references: [key, tournamentKey], onDelete: Cascade) + + @@id([afterMatchKey, beforeMatchKey]) + @@index([tournamentKey, startTime]) +} + +enum TournamentGapType { + LUNCH + OVERNIGHT // EOD on the previous day, followed by a new day of matches. + PLAYOFF_TRANSITION + DELAY + BREAK +} + +enum TournamentGapTimingSource { + ACTUAL + SCHEDULED + PREDICTED +} + model TeamMatchData { - key String @id + key String @id tournamentKey String - matchNumber Int @db.SmallInt + matchNumber Int @db.SmallInt teamNumber Int matchType MatchType - scoutReports ScoutReport[] - tournament Tournament @relation(fields: [tournamentKey], references: [key], onDelete: Cascade) + matchKey String? + alliance AllianceColor? + station Int? // SQL CHECK constraint permits null or stations 1–3. + + externalParticipantKey String? + + disqualified Boolean? + surrogate Boolean? + + tournament Tournament @relation(fields: [tournamentKey], references: [key], onDelete: Cascade) + team Team @relation(fields: [teamNumber], references: [number]) + match Match? @relation(fields: [matchKey], references: [key], onDelete: SetNull) + + scoutReports ScoutReport[] // Existing model stays unchanged. + + @@unique([matchKey, alliance, station]) @@index([tournamentKey, teamNumber]) + @@index([matchKey, teamNumber]) +} + +enum AllianceColor { + RED + BLUE +} + +enum CompetitionLevel { + QUALIFICATION + EIGHTHFINAL + QUARTERFINAL + SEMIFINAL + FINAL +} + +enum MatchStatus { + SCHEDULED + IN_PROGRESS + COMPLETED + CANCELED } model MutablePicklist { @@ -135,12 +377,6 @@ model SharedPicklist { author User @relation(fields: [authorId], references: [id], onDelete: Cascade) } -model Team { - number Int @id - name String - registeredTeam RegisteredTeam? -} - model RegisteredTeam { number Int @id code String @unique @@ -192,17 +428,6 @@ model SlackNotificationThread { channel SlackSubscription @relation(fields: [subscriptionId], references: [subscriptionId], onDelete: Cascade) } -model Tournament { - key String @id - name String - location String? - date String? - mutablePicklists MutablePicklist[] - scouterScheduleShifts ScouterScheduleShift[] - teamMatchData TeamMatchData[] - latestFetchETag String? -} - model User { id String @id teamNumber Int? diff --git a/packages/db/test/migrations.test.mjs b/packages/db/test/migrations.test.mjs index 97837b3c..52deafdb 100644 --- a/packages/db/test/migrations.test.mjs +++ b/packages/db/test/migrations.test.mjs @@ -22,12 +22,295 @@ const names = [ const sql = await Promise.all( names.map((name) => readFile( - new URL(`../prisma/migrations_backup/${name}/migration.sql`, import.meta.url), + new URL( + `../prisma/migrations_backup/${name}/migration.sql`, + import.meta.url, + ), "utf8", ), ), ); +const baselineSql = await readFile( + new URL("../prisma/migrations/0_baseline/migration.sql", import.meta.url), + "utf8", +); +const competitionSql = await readFile( + new URL( + "../prisma/migrations/20261010180215_add_season_match_and_more/migration.sql", + import.meta.url, + ), + "utf8", +); +const importSql = await readFile( + new URL( + "../prisma/migrations/20261010193000_add_import_fetch_state_and_jobs/migration.sql", + import.meta.url, + ), + "utf8", +); + +async function withBaselineDatabase(run) { + const admin = new pg.Client({ connectionString: url.href }); + const name = `lovat_migration_${randomUUID().replaceAll("-", "")}`; + let client, + created = false; + try { + await admin.connect(); + await admin.query(`CREATE DATABASE "${name}"`); + created = true; + const target = new URL(url); + target.pathname = `/${name}`; + client = new pg.Client({ connectionString: target.href }); + await client.connect(); + await client.query(baselineSql); + await client.query(` + INSERT INTO "Team" (number, name) VALUES (8033, 'Existing team'); + INSERT INTO "RegisteredTeam" (number, code, email) + VALUES (8033, 'fixture', 'fixture@example.invalid'); + INSERT INTO "Scouter" (uuid, "sourceTeamNumber") VALUES ('scouter', 8033); + INSERT INTO "Tournament" (key, name) VALUES ('2026fixture', 'Fixture'); + INSERT INTO "TeamMatchData" (key, "tournamentKey", "matchNumber", "teamNumber", "matchType") + VALUES ('missing-team', '2026fixture', 1, 254, 'QUALIFICATION'), + ('known-team', '2026fixture', 1, 8033, 'QUALIFICATION'); + INSERT INTO "ScoutReport" (uuid, "teamMatchKey", "startTime", notes, + "driverAbility", "scouterUuid", beached, "defenseEffectiveness", + "intakeType", "fieldTraversal", "scoresWhileMoving", disrupts, + "endgameClimb", "autoClimb") + VALUES ('report', 'missing-team', CURRENT_TIMESTAMP, 'Preserve this note', + 3, 'scouter', 'NEITHER', 0, 'NEITHER', 'NONE', false, false, + 'NOT_ATTEMPTED', 'NOT_ATTEMPTED'); + INSERT INTO "Event" ("eventUuid", time, action, position, points, quantity, "scoutReportUuid") + VALUES ('action', 10, 'INTAKE', 'DEPOT', 0, 2, 'report'); + `); + await run(client); + } finally { + await client?.end(); + if (created) await admin.query(`DROP DATABASE "${name}"`); + await admin.end(); + } +} + +test("gap migration preserves existing reports and enforces event boundaries", async () => { + const gapSql = await readFile( + new URL( + "../prisma/migrations/20261010220000_add_tournament_gaps/migration.sql", + import.meta.url, + ), + "utf8", + ); + + await withBaselineDatabase(async (client) => { + await client.query(competitionSql); + await client.query(importSql); + const reports = (await client.query('SELECT * FROM "ScoutReport"')).rows; + const actions = (await client.query('SELECT * FROM "Event"')).rows; + + await client.query(gapSql); + assert.deepEqual( + (await client.query('SELECT * FROM "ScoutReport"')).rows, + reports, + ); + assert.deepEqual( + (await client.query('SELECT * FROM "Event"')).rows, + actions, + ); + + await client.query(` + INSERT INTO "Tournament" (key, name) VALUES ('other-event', 'Other'); + INSERT INTO "Match" (key, "tournamentKey", "competitionLevel", "setNumber", "matchNumber") + VALUES ('before', '2026fixture', 'QUALIFICATION', 1, 1), + ('after', '2026fixture', 'QUALIFICATION', 1, 2), + ('other', 'other-event', 'QUALIFICATION', 1, 1); + `); + + const insert = `INSERT INTO "TournamentGap" ("tournamentKey", "afterMatchKey", "beforeMatchKey", type, "timingSource", "startTime", "endTime") VALUES ('2026fixture', 'before', $1, 'LUNCH', 'SCHEDULED', '2026-03-06 12:03:00', $2)`; + + await assert.rejects( + client.query(insert, ["other", "2026-03-06 13:00:00"]), + /foreign key/, + ); + await assert.rejects( + client.query(insert, ["after", "2026-03-06 11:00:00"]), + /check constraint/, + ); + await client.query(insert, ["after", "2026-03-06 13:00:00"]); + await client.query(`DELETE FROM "Match" WHERE key = 'after'`); + assert.equal( + (await client.query('SELECT * FROM "TournamentGap"')).rowCount, + 0, + ); + assert.deepEqual( + (await client.query('SELECT * FROM "ScoutReport"')).rows, + reports, + ); + }); +}); + +test("competition migration backfills missing teams and preserves reports and actions", async () => { + await withBaselineDatabase(async (client) => { + const reportsBefore = (await client.query('SELECT * FROM "ScoutReport"')) + .rows; + const actionsBefore = (await client.query('SELECT * FROM "Event"')).rows; + await client.query(competitionSql); + assert.deepEqual( + (await client.query('SELECT * FROM "ScoutReport"')).rows, + reportsBefore, + ); + assert.deepEqual( + (await client.query('SELECT * FROM "Event"')).rows, + actionsBefore, + ); + assert.deepEqual( + (await client.query('SELECT * FROM "Team" ORDER BY number')).rows, + [ + { number: 254, name: "Team 254" }, + { number: 8033, name: "Existing team" }, + ], + ); + const slots = (await client.query('SELECT * FROM "TeamMatchData"')).rows; + assert.equal(slots.length, 2); + assert.ok( + slots.every((slot) => slot.matchKey === null && slot.station === null), + ); + await assert.rejects( + client.query( + `UPDATE "TeamMatchData" SET station = 0 WHERE key = 'missing-team'`, + ), + (error) => error.code === "23514", + ); + await assert.rejects( + client.query( + `UPDATE "TeamMatchData" SET station = 4 WHERE key = 'missing-team'`, + ), + (error) => error.code === "23514", + ); + await client.query( + `UPDATE "TeamMatchData" SET station = 3 WHERE key = 'missing-team'`, + ); + await assert.rejects( + client.query( + `UPDATE "TeamMatchData" SET "teamNumber" = 99999 WHERE key = 'missing-team'`, + ), + (error) => error.code === "23503", + ); + }); +}); + +test("competition migration rolls back the backfill and schema on failure", async () => { + await withBaselineDatabase(async (client) => { + // Force a DDL conflict after the backfill has run. + await client.query(`CREATE TYPE "AllianceColor" AS ENUM ('RED', 'BLUE')`); + await assert.rejects( + client.query(competitionSql), + (error) => error.code === "42710", + ); + await client.query("ROLLBACK"); + assert.equal( + (await client.query('SELECT * FROM "Team" WHERE number = 254')).rowCount, + 0, + ); + assert.equal( + (await client.query('SELECT * FROM "ScoutReport"')).rowCount, + 1, + ); + assert.equal((await client.query('SELECT * FROM "Event"')).rowCount, 1); + assert.equal( + (await client.query(`SELECT to_regclass('public."Season"') AS name`)) + .rows[0].name, + null, + ); + }); +}); + +test("import migration preserves existing data and supports endpoint state and deduplicated jobs", async () => { + await withBaselineDatabase(async (client) => { + await client.query(competitionSql); + const reportsBefore = (await client.query('SELECT * FROM "ScoutReport"')) + .rows; + const actionsBefore = (await client.query('SELECT * FROM "Event"')).rows; + await client.query(importSql); + assert.deepEqual( + (await client.query('SELECT * FROM "ScoutReport"')).rows, + reportsBefore, + ); + assert.deepEqual( + (await client.query('SELECT * FROM "Event"')).rows, + actionsBefore, + ); + + await client.query(` + INSERT INTO "FetchState" (provider, "resourceKey", etag) + VALUES ('tba', 'event/2026fixture/matches', 'original'), + ('first', 'event/2026fixture/matches', NULL), + ('tba', 'event/2026fixture/teams', NULL); + INSERT INTO "ImportJob" (id, kind, "targetKey") + VALUES ('matches-job', 'eventMatches', '2026fixture'), + ('roster-job', 'eventRoster', '2026fixture'); + `); + await assert.rejects( + client.query(` + INSERT INTO "FetchState" (provider, "resourceKey") + VALUES ('tba', 'event/2026fixture/matches') + `), + (error) => error.code === "23505", + ); + await assert.rejects( + client.query(` + INSERT INTO "ImportJob" (id, kind, "targetKey") + VALUES ('duplicate', 'eventMatches', '2026fixture') + `), + (error) => error.code === "23505", + ); + const job = ( + await client.query(`SELECT * FROM "ImportJob" WHERE id = 'matches-job'`) + ).rows[0]; + assert.equal(job.attempts, 0); + assert.equal(job.leaseToken, null); + assert.equal(job.leaseExpiresAt, null); + assert.equal(job.lastError, null); + assert.ok(job.runAt instanceof Date); + + // Cache validators must roll back with the data they describe. + await client.query("BEGIN"); + await client.query( + `UPDATE "FetchState" SET etag = 'new' WHERE provider = 'tba' AND "resourceKey" = 'event/2026fixture/matches'`, + ); + await client.query( + `UPDATE "Tournament" SET name = 'Uncommitted' WHERE key = '2026fixture'`, + ); + await assert.rejects( + client.query( + `UPDATE "TeamMatchData" SET station = 4 WHERE key = 'missing-team'`, + ), + (error) => error.code === "23514", + ); + await client.query("ROLLBACK"); + assert.equal( + ( + await client.query( + `SELECT etag FROM "FetchState" WHERE provider = 'tba' AND "resourceKey" = 'event/2026fixture/matches'`, + ) + ).rows[0].etag, + "original", + ); + assert.equal( + ( + await client.query( + `SELECT name FROM "Tournament" WHERE key = '2026fixture'`, + ) + ).rows[0].name, + "Fixture", + ); + + // Completed jobs can be removed and scheduled again for the same target. + await client.query(`DELETE FROM "ImportJob" WHERE id = 'matches-job'`); + await client.query( + `INSERT INTO "ImportJob" (id, kind, "targetKey") VALUES ('next-job', 'eventMatches', '2026fixture')`, + ); + }); +}); + async function withLegacyDatabase(run) { const admin = new pg.Client({ connectionString: url.href }); const name = `lovat_migration_${randomUUID().replaceAll("-", "")}`; diff --git a/packages/db/test/report.test.mjs b/packages/db/test/report.test.mjs index 4cbf114b..73a74f74 100644 --- a/packages/db/test/report.test.mjs +++ b/packages/db/test/report.test.mjs @@ -125,8 +125,8 @@ test("report and event writes commit together, roll back together, and cascade", 0, ); } finally { - await db.team.deleteMany({ where: { number: teamNumber } }); await db.tournament.deleteMany({ where: { key: tournamentKey } }); + await db.team.deleteMany({ where: { number: teamNumber } }); await db.$disconnect(); } });