Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/.internal-tests/participant-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,15 +867,15 @@ 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) =>
table === "hackathons" ? runningHackathon(-24) : undefined,
);
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"',
);
});

Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/.internal-tests/qr-checkin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
);
Expand Down Expand Up @@ -861,15 +861,15 @@ 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 () => {
const caller = scannerCtx({}); // the guard read still says "not scanned"
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" },
);
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/routers/hackathon/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/routers/hackathon/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand Down
8 changes: 4 additions & 4 deletions packages/db/src/schemas/bootcamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
pgTable,
text,
timestamp,
unique,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { users } from "./auth";
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions packages/db/src/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
boolean,
integer,
index,
unique,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions packages/db/src/schemas/hackathons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
),
Expand Down Expand Up @@ -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),
],
);

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
),
Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/schemas/initiatives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
integer,
index,
unique,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./auth";
Expand Down Expand Up @@ -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,
),
Expand Down
10 changes: 6 additions & 4 deletions packages/db/src/schemas/judge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
integer,
index,
uniqueIndex,
unique,
numeric,
} from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
Expand Down Expand Up @@ -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),
],
);

Expand All @@ -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,
),
Expand Down
Loading