From acef4d3eaf4cfcd184fcd892c9e96340808582d6 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 1 Oct 2026 17:01:43 -0400 Subject: [PATCH 1/2] fix(db): make check-in and judge uniqueness unique indexes Same drizzle-kit bug as event_bootcamp_session_idx: constraint columns are introspected with no ORDER BY. Against prod, unique_judge_per_hackathon and unique_assignment_per_hackathon are dropped and re-added on every push. Both tables are empty today, so push passes; the first deploy after a judge registers would stop at the TTY prompt. unique_event_check_in has 36 rows and would stall the moment its introspected order flips. Unique indexes are introspected by column position. Verified on a restored prod dump: the first push swaps all three, later pushes leave them alone, db:check passes, check-in rows are kept. --- packages/api/src/.internal-tests/qr-checkin.test.ts | 2 +- packages/db/src/schemas/events.ts | 10 +++++++--- packages/db/src/schemas/judge.ts | 10 ++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index 996cd672..e0a58d27 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -616,7 +616,7 @@ describe("QR check-in", () => { ) { throw Object.assign( new Error( - 'duplicate key value violates unique constraint "unique_event_check_in"', + 'duplicate key value violates unique constraint "event_check_in_event_user_idx"', ), { code: "23505" }, ); diff --git a/packages/db/src/schemas/events.ts b/packages/db/src/schemas/events.ts index f7ec73b8..accb5033 100644 --- a/packages/db/src/schemas/events.ts +++ b/packages/db/src/schemas/events.ts @@ -6,7 +6,6 @@ import { boolean, integer, index, - unique, uniqueIndex, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; @@ -81,8 +80,13 @@ export const eventCheckIns = pgTable( (table) => [ // A person is at a given event once. events.checkIn already treats a 23505 // here as "Already checked in" and its row lock keeps the QR door honest, but - // the constraint is what holds for any future manual or imported check-in. - unique("unique_event_check_in").on(table.eventId, table.userId), + // the index is what holds for any future manual or imported check-in. A + // unique index rather than a constraint for the same reason as + // event_bootcamp_session_idx above. + uniqueIndex("event_check_in_event_user_idx").on( + table.eventId, + table.userId, + ), // The unique above leads with eventId, so a lookup by user alone cannot use // it. events.myEvents and myStats filter on exactly userId and run on every // portal dashboard load — without this they scan the whole check-in table. diff --git a/packages/db/src/schemas/judge.ts b/packages/db/src/schemas/judge.ts index af1896dc..d1616b48 100644 --- a/packages/db/src/schemas/judge.ts +++ b/packages/db/src/schemas/judge.ts @@ -7,7 +7,6 @@ import { integer, index, uniqueIndex, - unique, numeric, } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; @@ -41,10 +40,13 @@ export const judges = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - // user_id alone leads the unique constraint below, which is what the portal's + // user_id alone leads the unique index below, which is what the portal's // per-user judge lookup uses. index("judge_hackathon_id_idx").on(table.hackathonId), - unique("unique_judge_per_hackathon").on(table.userId, table.hackathonId), + // Unique indexes, not constraints, here and on judge_assignment: drizzle-kit + // reads a constraint's columns unordered, so push re-adds it every deploy and + // stops at a TTY prompt once the table has rows. See events.ts. + uniqueIndex("judge_user_hackathon_idx").on(table.userId, table.hackathonId), ], ); @@ -71,7 +73,7 @@ export const judgeAssignments = pgTable( index("assignment_hackathon_id_idx").on(table.hackathonId), // assignToHackathon and judge.register both enforce one assignment per judge // per hackathon with a read before the insert. - unique("unique_assignment_per_hackathon").on( + uniqueIndex("assignment_judge_hackathon_idx").on( table.judgeId, table.hackathonId, ), From ca81bec458dc9be54681f472a83989444b577e24 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 1 Oct 2026 17:23:54 -0400 Subject: [PATCH 2/2] fix(db): make remaining composite uniques unique indexes Converts the last six multi-column unique constraints (bootcamp workshop, hackathon participant, interest, event attendee, announcement recipient, initiative application) to unique indexes, for the same drizzle-kit introspection bug as the previous commit. hackathon_interest (123 rows) and initiative_application (74 rows) would stall a deploy at the TTY prompt the first time push read their columns out of order. registration.ts matched the old constraint name as a fallback to the 23505 check; it now matches the index name, as do the test mocks. Verified on a restored prod dump: one push swaps all nine with no prompt, later pushes leave them alone, db:check passes, row counts unchanged. api tests: 604 passed. --- .../src/.internal-tests/hackathon-admin-edge.test.ts | 4 ++-- .../api/src/.internal-tests/participant-edge.test.ts | 4 ++-- packages/api/src/.internal-tests/qr-checkin.test.ts | 4 ++-- packages/api/src/routers/hackathon/admin.ts | 3 ++- packages/api/src/routers/hackathon/registration.ts | 6 +++--- packages/db/src/schemas/bootcamp.ts | 8 ++++---- packages/db/src/schemas/hackathons.ts | 12 ++++++------ packages/db/src/schemas/initiatives.ts | 3 ++- 8 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index 7a4205bf..41b09226 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -890,13 +890,13 @@ describe("Hackathon admin management edge cases", () => { }); // BUG: the duplicate guard is a findFirst followed by an unguarded insert. - // unique('unique_event_participant') turns the losing racer's scan into a + // uniqueIndex('event_attendee_event_participant_idx') turns the losing racer's scan into a // raw 23505 -> INTERNAL_SERVER_ERROR instead of the friendly CONFLICT. it("tells the volunteer 'already checked in' when two scanners race", async () => { const caller = scanCtx(); mockInsert.mockImplementation(() => { const err: any = new Error( - 'duplicate key value violates unique constraint "unique_event_participant"', + 'duplicate key value violates unique constraint "event_attendee_event_participant_idx"', ); err.code = "23505"; throw err; diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index 8cc7718c..87f1b39b 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -867,7 +867,7 @@ describe("Participant edge cases", () => { // ===================================================================== describe("6. Registration under contention", () => { // BUG: the duplicate guard at registration.ts:95-108 is an unlocked - // findFirst; the unique_participant_per_hackathon violation that follows is + // findFirst; the hackathon_participant_hackathon_user_idx violation that follows is // not a TRPCError, so registration.ts:182 rethrows it as a 500. it("reports a double-submitted registration form as already registered", async () => { mockFindFirst.mockImplementation((table) => @@ -875,7 +875,7 @@ describe("Participant edge cases", () => { ); mockInsert.mockImplementation(() => { throw new Error( - 'duplicate key value violates unique constraint "unique_participant_per_hackathon"', + 'duplicate key value violates unique constraint "hackathon_participant_hackathon_user_idx"', ); }); diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index e0a58d27..32a0bb17 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -861,7 +861,7 @@ describe("QR check-in", () => { }); // BUG: admin.ts:318-321 inserts with no try/catch, so when the - // unique('unique_event_participant') index (schemas/hackathons.ts:310) + // unique('event_attendee_event_participant_idx') index (schemas/hackathons.ts:310) // rejects the loser of a double-tap, errorFormatter (trpc.ts:24-28) masks // it as "An unexpected error occurred" on the volunteer's screen. it("shows a duplicate scan as a readable conflict, not a system failure", async () => { @@ -869,7 +869,7 @@ describe("QR check-in", () => { mockInsert.mockImplementation(() => { throw Object.assign( new Error( - 'duplicate key value violates unique constraint "unique_event_participant"', + 'duplicate key value violates unique constraint "event_attendee_event_participant_idx"', ), { code: "23505" }, ); diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index b2e2fd88..65c19076 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -950,7 +950,8 @@ export const hackathonAdminRouter = createTRPCRouter({ }); } - // 4. Record attendance. unique('unique_event_participant') is the real guard: + // 4. Record attendance. The unique index event_attendee_event_participant_idx + // is the real guard: // two scanners can both pass the read above, so the loser's 23505 has to read // as the same conflict, not a raw INTERNAL_SERVER_ERROR. // Attendance and the roster promotion are one transaction. Split, a promotion diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index b3c82018..84b90b30 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -11,7 +11,7 @@ import { eq, and, sql } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; import { assertHackathonVisible } from "./visibility"; -// Postgres unique_violation on unique_participant_per_hackathon — a second +// Postgres unique_violation on hackathon_participant_hackathon_user_idx — a second // submission of the same form. Drizzle wraps every driver error in a // DrizzleQueryError whose own `code` is undefined; the pg error carrying the // SQLSTATE sits on `.cause`, so the chain has to be walked. @@ -25,9 +25,9 @@ const isDuplicateRegistration = (error: unknown) => { cause?: unknown; }; if (candidate.code === "23505") return true; - if (candidate.constraint === "unique_participant_per_hackathon") + if (candidate.constraint === "hackathon_participant_hackathon_user_idx") return true; - if (candidate.message?.includes("unique_participant_per_hackathon")) + if (candidate.message?.includes("hackathon_participant_hackathon_user_idx")) return true; cursor = candidate.cause; } diff --git a/packages/db/src/schemas/bootcamp.ts b/packages/db/src/schemas/bootcamp.ts index 8609bc32..231ca685 100644 --- a/packages/db/src/schemas/bootcamp.ts +++ b/packages/db/src/schemas/bootcamp.ts @@ -5,7 +5,7 @@ import { pgTable, text, timestamp, - unique, + uniqueIndex, uuid, } from "drizzle-orm/pg-core"; import { users } from "./auth"; @@ -47,9 +47,9 @@ export const bootcampWorkshops = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - // Declaration order matches the column order because drizzle push - // otherwise sees a persistent constraint diff. - unique("unique_bootcamp_workshop").on(table.term, table.week), + // A unique index, not a constraint: drizzle-kit reads constraint columns + // unordered and re-adds them on push. See event_bootcamp_session_idx. + uniqueIndex("bootcamp_workshop_term_week_idx").on(table.term, table.week), // Member reads filter one term and published state together. index("bootcamp_workshop_term_published_idx").on( table.term, diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 64d0c602..a85b6546 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -186,7 +186,7 @@ export const hackathonParticipants = pgTable( }, (table) => [ // No standalone hackathon_id index: it leads both the composite below and - // unique_participant_per_hackathon, so a lookup by hackathon already has two + // hackathon_participant_hackathon_user_idx, so a lookup by hackathon already has two // to choose from. A third only made every insert write another entry. index("participant_user_id_idx").on(table.userId), index("participant_team_id_idx").on(table.teamId), @@ -201,7 +201,7 @@ export const hackathonParticipants = pgTable( ), // One registration per user per hackathon at the DB level, so duplicates // cannot race past the findFirst inside the transaction. - unique("unique_participant_per_hackathon").on( + uniqueIndex("hackathon_participant_hackathon_user_idx").on( table.hackathonId, table.userId, ), @@ -315,13 +315,13 @@ export const hackathonInterest = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - // hackathon_id alone leads unique_interest_per_hackathon below, which already + // hackathon_id alone leads hackathon_interest_hackathon_user_idx below, which already // serves every by-edition read. index("hackathon_interest_user_id_idx").on(table.userId), // Registering interest twice is one person changing their answers. The unique // index is what makes the upsert in registerInterest safe against a double // submit. - unique("unique_interest_per_hackathon").on(table.hackathonId, table.userId), + uniqueIndex("hackathon_interest_hackathon_user_idx").on(table.hackathonId, table.userId), ], ); @@ -427,7 +427,7 @@ export const hackathonEventAttendees = pgTable( index("event_attendee_event_id_idx").on(table.eventId), index("event_attendee_participant_id_idx").on(table.participantId), // Prevent duplicate check-ins - unique("unique_event_participant").on(table.eventId, table.participantId), + uniqueIndex("event_attendee_event_participant_idx").on(table.eventId, table.participantId), // Named explicitly: the generated name is 67 chars, Postgres truncates to 63, // and drizzle then sees a diff on every push and re-creates it forever. foreignKey({ @@ -548,7 +548,7 @@ export const hackathonAnnouncementRecipients = pgTable( ), // One delivery per person per announcement, enforced by the database rather // than by the batching arithmetic that used to get it wrong. - unique("unique_announcement_recipient").on( + uniqueIndex("announcement_recipient_announcement_user_idx").on( table.announcementId, table.userId, ), diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts index 0aa91f94..5ea20dd7 100644 --- a/packages/db/src/schemas/initiatives.ts +++ b/packages/db/src/schemas/initiatives.ts @@ -7,6 +7,7 @@ import { integer, index, unique, + uniqueIndex, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { users } from "./auth"; @@ -146,7 +147,7 @@ export const initiativeApplications = pgTable( (table) => [ index("initiative_application_initiative_idx").on(table.initiativeId), index("initiative_application_user_idx").on(table.userId), - unique("unique_application_per_initiative").on( + uniqueIndex("initiative_application_initiative_user_idx").on( table.initiativeId, table.userId, ),