svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index f7ce98c..8982a02 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,12 +21,12 @@ services:
network_mode: "service:app"
restart: unless-stopped
- worker:
- build:
- context: .
- target: video-upload-worker
- restart: unless-stopped
- env_file:
- - .env
- volumes:
- - /nfs/erika:/nfs/erika
+ # worker:
+ # build:
+ # context: .
+ # target: video-upload-worker
+ # restart: unless-stopped
+ # env_file:
+ # - .env
+ # volumes:
+ # - /nfs/erika:/nfs/erika
diff --git a/docs/adr/0003-personal-guild-profile.md b/docs/adr/0003-personal-guild-profile.md
new file mode 100644
index 0000000..76d23d0
--- /dev/null
+++ b/docs/adr/0003-personal-guild-profile.md
@@ -0,0 +1,42 @@
+# ADR 0003: Personal guild profile read model
+
+- Status: Accepted
+- Date: 2026-08-06
+
+## Context
+
+Signed-in members need one private page that combines their Discord identity with XP,
+level progress, voice time, and leaderboard standing. The Discord bot remains the
+authoritative writer for `user_levels` and `voice_time`, while the website has only a
+read-only connection to those tables. XP values use `numeric(40,0)`, which may exceed
+JavaScript's safe integer range.
+
+## Decision
+
+1. `/profile` identifies the current member from the server-side NextAuth session and
+ Discord provider account. Anonymous visitors receive a Discord login prompt that
+ returns them to `/profile`; profiles cannot be selected by URL parameter.
+2. Discord identity, XP, and voice data load independently. Session identity is the
+ fallback when Discord member metadata is unavailable, and unavailable activity
+ sources report section-level errors.
+3. The stored bot level remains authoritative. Next-level progress duplicates the
+ bot's documented XP formula and performs all XP arithmetic with `bigint` before
+ converting the bounded percentage to a number. Level 200 is the maximum and does
+ not display a progress bar.
+4. XP and voice ranks use the public leaderboards' shared competition ranking. XP
+ rank excludes level-0 members. Voice time and rank include only voice and Stage
+ channels that currently exist in the guild.
+5. Missing activity rows display zero activity and no rank. The website does not
+ create rows or write inferred values back to the bot database.
+6. One public leaderboard SSE subscription refreshes an open profile after either an
+ XP event or a voice-time event. Redis remains a notification path rather than the
+ source of truth.
+
+## Consequences
+
+- A signed-in member sees only the profile associated with their own Discord account.
+- Profile totals and public leaderboard values share ranking and voice-channel scope.
+- The bot formula is a cross-service contract. Formula or maximum-level changes must
+ update the bot integration document and website helper together.
+- Large cumulative XP values remain exact through database reads, calculations, and
+ formatting.
diff --git a/docs/glossary.md b/docs/glossary.md
index 25071ac..fc96f0d 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -63,3 +63,15 @@ tables, combined with current Discord channel metadata.
A minimal Redis notification on `erika:sse:leaderboards` that tells an open XP or VC
page to refresh its Server Component data after a bot transaction commits.
+
+## Guild profile
+
+The private `/profile` view for the currently authenticated Discord member. It joins
+Discord identity and roles with that member's level, XP, current-channel voice time,
+and leaderboard ranks without exposing a public member-profile URL.
+
+## Level progress
+
+The member's XP accumulated since reaching their stored level, measured against the
+bot's XP requirement for the next level. Level 200 is the maximum and has no next-level
+progress bar.
diff --git a/docs/integrations/leaderboard-bot.md b/docs/integrations/leaderboard-bot.md
index 6d49a91..b53e1ea 100644
--- a/docs/integrations/leaderboard-bot.md
+++ b/docs/integrations/leaderboard-bot.md
@@ -3,6 +3,28 @@
The Discord bot owns all writes to `user_levels` and `voice_time`. The website only
subscribes to notifications and then reads the committed database state.
+## Level formula contract
+
+The website's personal profile calculates next-level progress from the bot's stored
+level and cumulative XP. Both services must use this exact formula:
+
+```text
+required(0) = 500
+required(level) = required(level - 1) + 100 + (level * 25)
+total(0) = 0
+total(level) = total(level - 1) + required(level - 1)
+```
+
+`required(level)` is the XP needed to advance from that level to the next. `total(level)`
+is the cumulative XP needed to reach that level. Level 200 is the maximum; the XP
+requirement caps at the level-199 value and the profile does not show next-level
+progress at the cap.
+
+Store cumulative XP as an integer value and update `xp` and `level` in the same bot
+transaction. Changing the formula or maximum level requires a coordinated website
+update because the website intentionally does not infer the formula from database
+metadata.
+
## Redis channel
Publish leaderboard notifications to exactly:
diff --git a/lib/discord/discord.ts b/lib/discord/discord.ts
index 986f32f..9c2a5e6 100644
--- a/lib/discord/discord.ts
+++ b/lib/discord/discord.ts
@@ -1,4 +1,8 @@
import "server-only";
+import {
+ discordAvatarDecorationUrl,
+ discordGuildTagBadgeUrl,
+} from "@/lib/discord/profile-appearance";
export interface DiscordRole {
id: string;
@@ -12,8 +16,10 @@ export interface DiscordMemberProfile {
username: string;
globalName: string | null;
avatarUrl: string | null;
+ avatarDecorationUrl: string | null;
bannerUrl: string | null;
accentColor: number | null;
+ primaryGuild: { tag: string; badgeUrl: string | null } | null;
roles: DiscordRole[];
}
@@ -136,48 +142,78 @@ export async function getDiscordMemberProfile(discordId: string): Promise allRoles.find((role) => role.id === id))
.filter((role): role is DiscordRole => Boolean(role))
.sort((a, b) => b.position - a.position);
- const { user } = member;
+ const user = { ...member.user, ...publicUser };
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith("a_") ? "gif" : "png"}?size=128`
: null;
const bannerUrl = user.banner
? `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${user.banner.startsWith("a_") ? "gif" : "png"}?size=512`
: null;
+ const primaryGuild = user.primary_guild?.identity_enabled &&
+ user.primary_guild.identity_guild_id && user.primary_guild.tag
+ ? {
+ tag: user.primary_guild.tag,
+ badgeUrl: discordGuildTagBadgeUrl(
+ user.primary_guild.identity_guild_id,
+ user.primary_guild.badge,
+ ),
+ }
+ : null;
const result = {
id: user.id,
username: user.username,
globalName: user.global_name ?? null,
avatarUrl,
+ avatarDecorationUrl: discordAvatarDecorationUrl(
+ user.avatar_decoration_data?.asset,
+ ),
bannerUrl,
accentColor: user.accent_color ?? null,
+ primaryGuild,
roles,
};
diff --git a/lib/discord/profile-appearance.test.ts b/lib/discord/profile-appearance.test.ts
new file mode 100644
index 0000000..3d416ce
--- /dev/null
+++ b/lib/discord/profile-appearance.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+import {
+ discordAvatarDecorationUrl,
+ discordColorToHex,
+ discordGuildTagBadgeUrl,
+} from "@/lib/discord/profile-appearance";
+
+describe("Discord profile appearance", () => {
+ test("formats Discord accent colors", () => {
+ expect(discordColorToHex(0xff00aa)).toBe("#ff00aa");
+ });
+
+ test("builds the documented avatar decoration CDN URL", () => {
+ expect(discordAvatarDecorationUrl("a_frame_asset")).toBe(
+ "https://cdn.discordapp.com/avatar-decoration-presets/a_frame_asset.png?size=256",
+ );
+ expect(discordAvatarDecorationUrl(null)).toBeNull();
+ });
+
+ test("builds the documented primary guild badge CDN URL", () => {
+ expect(discordGuildTagBadgeUrl("123", "badge_hash")).toBe(
+ "https://cdn.discordapp.com/guild-tag-badges/123/badge_hash.png?size=64",
+ );
+ expect(discordGuildTagBadgeUrl("123", null)).toBeNull();
+ });
+});
diff --git a/lib/discord/profile-appearance.ts b/lib/discord/profile-appearance.ts
new file mode 100644
index 0000000..69c3124
--- /dev/null
+++ b/lib/discord/profile-appearance.ts
@@ -0,0 +1,18 @@
+export function discordColorToHex(value: number) {
+ return `#${value.toString(16).padStart(6, "0")}`;
+}
+
+export function discordAvatarDecorationUrl(asset: string | null | undefined) {
+ return asset
+ ? `https://cdn.discordapp.com/avatar-decoration-presets/${encodeURIComponent(asset)}.png?size=256`
+ : null;
+}
+
+export function discordGuildTagBadgeUrl(
+ guildId: string | null | undefined,
+ badge: string | null | undefined,
+) {
+ return guildId && badge
+ ? `https://cdn.discordapp.com/guild-tag-badges/${encodeURIComponent(guildId)}/${encodeURIComponent(badge)}.png?size=64`
+ : null;
+}
diff --git a/lib/leaderboard/database.ts b/lib/leaderboard/database.ts
index 5dc3848..66d0ad9 100644
--- a/lib/leaderboard/database.ts
+++ b/lib/leaderboard/database.ts
@@ -69,6 +69,29 @@ export async function getXpLeaderboardPage(
return buildXpLeaderboard(rows, requestedPage, searchQuery);
}
+export async function getMemberXpStats(userId: string) {
+ const database = getLeaderboardDatabase();
+ const [sourceRows, leaderboard] = await Promise.all([
+ database
+ .select({
+ level: userLevels.level,
+ totalXp: userLevels.xp,
+ })
+ .from(userLevels)
+ .where(eq(userLevels.userId, userId))
+ .limit(1),
+ getXpLeaderboardPage(1, userId),
+ ]);
+ const rankedRow = leaderboard.rows.find((row) => row.userId === userId);
+ const sourceRow = sourceRows[0];
+
+ return {
+ level: sourceRow?.level ?? 0,
+ totalXp: sourceRow?.totalXp ?? "0",
+ rank: rankedRow?.rank ?? null,
+ };
+}
+
export async function getVoiceLeaderboardPage(
currentChannelIds: string[],
requestedPage: number,
@@ -109,6 +132,24 @@ export async function getVoiceLeaderboardPage(
);
}
+export async function getMemberVoiceStats(
+ userId: string,
+ currentChannelIds: string[],
+) {
+ const leaderboard = await getVoiceLeaderboardPage(
+ currentChannelIds,
+ 1,
+ undefined,
+ userId,
+ );
+ const rankedRow = leaderboard.rows.find((row) => row.userId === userId);
+
+ return {
+ totalSeconds: rankedRow?.totalSeconds ?? "0",
+ rank: rankedRow?.rank ?? null,
+ };
+}
+
export async function getLeaderboardDatabaseReadOnlyStatus() {
const result = await getLeaderboardDatabase().execute<{
transaction_read_only: string;
diff --git a/lib/leaderboard/level-progress.test.ts b/lib/leaderboard/level-progress.test.ts
new file mode 100644
index 0000000..97b93b0
--- /dev/null
+++ b/lib/leaderboard/level-progress.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test";
+import {
+ buildLevelProgress,
+ totalXpForLevel,
+ xpRequiredForLevel,
+} from "@/lib/leaderboard/level-progress";
+
+describe("leaderboard level formula", () => {
+ test("matches the bot thresholds", () => {
+ expect(xpRequiredForLevel(0)).toBe(BigInt(500));
+ expect(xpRequiredForLevel(1)).toBe(BigInt(625));
+ expect(totalXpForLevel(1)).toBe(BigInt(500));
+ expect(totalXpForLevel(2)).toBe(BigInt(1_125));
+ });
+
+ test("calculates progress at level boundaries", () => {
+ expect(buildLevelProgress(0, "499")).toMatchObject({
+ xpIntoLevel: "499",
+ xpRequired: "500",
+ xpToNextLevel: "1",
+ percentage: 99.8,
+ });
+ expect(buildLevelProgress(1, "500")).toMatchObject({
+ xpIntoLevel: "0",
+ xpRequired: "625",
+ xpToNextLevel: "625",
+ percentage: 0,
+ });
+ });
+
+ test("clamps inconsistent and very large totals without losing precision", () => {
+ expect(buildLevelProgress(12, "1").percentage).toBe(0);
+
+ const huge = buildLevelProgress(12, "9999999999999999999999999999999999999999");
+ expect(huge.percentage).toBe(100);
+ expect(huge.totalXp).toBe("9999999999999999999999999999999999999999");
+ });
+
+ test("marks level 200 as max level and caps the formula", () => {
+ const maxLevel = buildLevelProgress(200, totalXpForLevel(200).toString());
+
+ expect(maxLevel.isMaxLevel).toBeTrue();
+ expect(xpRequiredForLevel(200)).toBe(xpRequiredForLevel(199));
+ });
+});
diff --git a/lib/leaderboard/level-progress.ts b/lib/leaderboard/level-progress.ts
new file mode 100644
index 0000000..b360bcf
--- /dev/null
+++ b/lib/leaderboard/level-progress.ts
@@ -0,0 +1,69 @@
+export const MAX_LEVEL = 200;
+
+export type LevelProgress = {
+ level: number;
+ totalXp: string;
+ xpIntoLevel: string;
+ xpRequired: string;
+ xpToNextLevel: string;
+ percentage: number;
+ isMaxLevel: boolean;
+};
+
+function asNonNegativeBigInt(value: string) {
+ try {
+ const parsed = BigInt(value);
+ return parsed > BigInt(0) ? parsed : BigInt(0);
+ } catch {
+ return BigInt(0);
+ }
+}
+
+function normalizeLevel(level: number) {
+ if (!Number.isFinite(level)) return 0;
+ return Math.min(MAX_LEVEL, Math.max(0, Math.trunc(level)));
+}
+
+/** XP needed to advance from level n to n + 1. Levels 200+ use the level-199 cap. */
+export function xpRequiredForLevel(level: number) {
+ const n = BigInt(Math.min(MAX_LEVEL - 1, normalizeLevel(level)));
+ return BigInt(500)
+ + (BigInt(100) * n)
+ + (BigInt(25) * n * (n + BigInt(1))) / BigInt(2);
+}
+
+/** Cumulative XP needed to reach a level from level 0. */
+export function totalXpForLevel(level: number) {
+ const targetLevel = normalizeLevel(level);
+ let total = BigInt(0);
+
+ for (let currentLevel = 0; currentLevel < targetLevel; currentLevel += 1) {
+ total += xpRequiredForLevel(currentLevel);
+ }
+
+ return total;
+}
+
+export function buildLevelProgress(level: number, rawTotalXp: string): LevelProgress {
+ const normalizedLevel = normalizeLevel(level);
+ const totalXp = asNonNegativeBigInt(rawTotalXp);
+ const required = xpRequiredForLevel(normalizedLevel);
+ const levelStart = totalXpForLevel(normalizedLevel);
+ const unboundedProgress = totalXp > levelStart
+ ? totalXp - levelStart
+ : BigInt(0);
+ const xpIntoLevel = unboundedProgress > required ? required : unboundedProgress;
+ const basisPoints = required === BigInt(0)
+ ? BigInt(0)
+ : (xpIntoLevel * BigInt(10_000)) / required;
+
+ return {
+ level: normalizedLevel,
+ totalXp: totalXp.toString(),
+ xpIntoLevel: xpIntoLevel.toString(),
+ xpRequired: required.toString(),
+ xpToNextLevel: (required - xpIntoLevel).toString(),
+ percentage: Number(basisPoints) / 100,
+ isMaxLevel: normalizedLevel >= MAX_LEVEL,
+ };
+}
diff --git a/lib/leaderboard/ranking.test.ts b/lib/leaderboard/ranking.test.ts
new file mode 100644
index 0000000..41e21a2
--- /dev/null
+++ b/lib/leaderboard/ranking.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test";
+import {
+ buildVoiceLeaderboard,
+ buildXpLeaderboard,
+} from "@/lib/leaderboard/ranking";
+
+describe("member leaderboard standing", () => {
+ test("keeps shared XP rank when filtering to one Discord ID", () => {
+ const leaderboard = buildXpLeaderboard([
+ { userId: "one", username: "one", displayName: null, level: 2, totalXp: "1000" },
+ { userId: "two", username: "two", displayName: null, level: 2, totalXp: "1000" },
+ { userId: "three", username: "three", displayName: null, level: 1, totalXp: "500" },
+ { userId: "zero", username: "zero", displayName: null, level: 0, totalXp: "499" },
+ ], 1, "two");
+
+ expect(leaderboard.rows).toHaveLength(1);
+ expect(leaderboard.rows[0]?.rank).toBe(1);
+ expect(leaderboard.rows.some((row) => row.userId === "zero")).toBeFalse();
+ });
+
+ test("sums only current voice channels before assigning rank", () => {
+ const leaderboard = buildVoiceLeaderboard([
+ { userId: "one", username: "one", displayName: null, channelId: "current-a", totalSeconds: "30" },
+ { userId: "one", username: "one", displayName: null, channelId: "current-b", totalSeconds: "45" },
+ { userId: "one", username: "one", displayName: null, channelId: "deleted", totalSeconds: "999" },
+ { userId: "two", username: "two", displayName: null, channelId: "current-a", totalSeconds: "60" },
+ ], ["current-a", "current-b"], 1, undefined, "one");
+
+ expect(leaderboard.rows).toHaveLength(1);
+ expect(leaderboard.rows[0]).toMatchObject({
+ userId: "one",
+ totalSeconds: "75",
+ rank: 1,
+ });
+ });
+});
diff --git a/lib/realtime/realtime-refresh.test.ts b/lib/realtime/realtime-refresh.test.ts
new file mode 100644
index 0000000..6a71f5a
--- /dev/null
+++ b/lib/realtime/realtime-refresh.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test";
+import {
+ startRealtimeRefresh,
+ type RealtimeRefreshHandlers,
+} from "@/lib/realtime/realtime-refresh";
+
+function waitForRefresh() {
+ return new Promise((resolve) => setTimeout(resolve, 5));
+}
+
+describe("profile realtime refresh", () => {
+ test("refreshes for both XP and voice events", async () => {
+ let handlers: RealtimeRefreshHandlers | undefined;
+ let refreshes = 0;
+ const clean = startRealtimeRefresh({
+ filter: { kind: "profile" },
+ debounceMs: 0,
+ refresh: () => { refreshes += 1; },
+ subscribe: (nextHandlers) => {
+ handlers = nextHandlers;
+ return { clean() {} };
+ },
+ });
+
+ handlers?.onXp();
+ await waitForRefresh();
+ handlers?.onVc({ channelId: "1234567890" });
+ await waitForRefresh();
+
+ expect(refreshes).toBe(2);
+ clean();
+ });
+
+ test("debounces profile event bursts and refreshes after reconnect", async () => {
+ let handlers: RealtimeRefreshHandlers | undefined;
+ let refreshes = 0;
+ const clean = startRealtimeRefresh({
+ filter: { kind: "profile" },
+ debounceMs: 1,
+ refresh: () => { refreshes += 1; },
+ subscribe: (nextHandlers) => {
+ handlers = nextHandlers;
+ return { clean() {} };
+ },
+ });
+
+ handlers?.onXp();
+ handlers?.onVc({ channelId: "1234567890" });
+ await waitForRefresh();
+ expect(refreshes).toBe(1);
+
+ handlers?.onOpen();
+ handlers?.onOpen();
+ await waitForRefresh();
+ expect(refreshes).toBe(2);
+ clean();
+ });
+});
diff --git a/lib/realtime/realtime-refresh.ts b/lib/realtime/realtime-refresh.ts
index 9dccfe3..f213e6c 100644
--- a/lib/realtime/realtime-refresh.ts
+++ b/lib/realtime/realtime-refresh.ts
@@ -1,6 +1,7 @@
export type RealtimeRefreshFilter =
| { kind: "form"; formId?: string }
| { kind: "uploads" }
+ | { kind: "profile" }
| { kind: "xp" }
| { kind: "vc"; channelId?: string };
@@ -50,15 +51,18 @@ export function startRealtimeRefresh({
}
},
onXp() {
- if (filter.kind === "xp") scheduleRefresh();
+ if (filter.kind === "xp" || filter.kind === "profile") scheduleRefresh();
},
onUpload() {
if (filter.kind === "uploads") scheduleRefresh();
},
onVc(event) {
if (
- filter.kind === "vc"
- && (!filter.channelId || event.channelId === filter.channelId)
+ filter.kind === "profile"
+ || (
+ filter.kind === "vc"
+ && (!filter.channelId || event.channelId === filter.channelId)
+ )
) {
scheduleRefresh();
}
diff --git a/scripts/apply-0011-migration.ts b/scripts/apply-0011-migration.ts
deleted file mode 100644
index 0e63355..0000000
--- a/scripts/apply-0011-migration.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { readFile } from "fs/promises";
-import postgres from "postgres";
-
-const migrationTag = "0011_unique_submission_user";
-const migrationPath = "drizzle/0011_unique_submission_user.sql";
-
-const databaseUrl = process.env.DATABASE_URL;
-if (!databaseUrl) {
- throw new Error("DATABASE_URL is not set");
-}
-
-const sql = postgres(databaseUrl, { max: 1 });
-
-try {
- const duplicates = await sql<{ form_id: string; user_discord_id: string; count: string }[]>`
- select form_id, user_discord_id, count(*)::text as count
- from form.submission
- where user_discord_id is not null
- group by form_id, user_discord_id
- having count(*) > 1
- limit 20
- `;
-
- if (duplicates.length > 0) {
- console.error("Duplicate submissions would violate the unique index:");
- for (const duplicate of duplicates) {
- console.error(
- `form_id=${duplicate.form_id} user_discord_id=${duplicate.user_discord_id} count=${duplicate.count}`
- );
- }
- process.exit(1);
- }
-
- await sql`
- create unique index if not exists "submission_form_user_discord_id_unique"
- on "form"."submission" using btree ("form_id", "user_discord_id")
- `;
-
- const migrationTable = await sql<{ exists: boolean }[]>`
- select to_regclass('drizzle.__drizzle_migrations') is not null as exists
- `;
-
- if (migrationTable[0]?.exists) {
- const existing = await sql<{ count: string }[]>`
- select count(*)::text as count
- from drizzle.__drizzle_migrations
- where hash = ${migrationTag}
- `;
-
- if (existing[0]?.count === "0") {
- const migrationSql = await readFile(migrationPath, "utf8");
- await sql`
- insert into drizzle.__drizzle_migrations (hash, created_at)
- values (${migrationTag}, ${Date.now()})
- `;
- console.log(`Applied ${migrationTag}: ${migrationSql.trim()}`);
- } else {
- console.log(`${migrationTag} was already recorded.`);
- }
- } else {
- console.log("Applied index. Drizzle migration table was not found, so no migration row was recorded.");
- }
-} finally {
- await sql.end();
-}
diff --git a/scripts/repair-form-migration-ledger.ts b/scripts/repair-form-migration-ledger.ts
deleted file mode 100644
index 5e2b60c..0000000
--- a/scripts/repair-form-migration-ledger.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import postgres from "postgres";
-
-const databaseUrl = process.env.DATABASE_URL;
-if (!databaseUrl) {
- throw new Error("DATABASE_URL is not set");
-}
-
-const expectedMigrations = [
- {
- hash: "418d5afb86b4866e697a0abfc992b913da1533e8df41cc0b618a9e23cbb70ba5",
- createdAt: 1782731728406,
- },
- {
- hash: "5e060f0fdac0296f4953f707182eb1c872f69d44e9ef89ba75c69fbc9c895851",
- createdAt: 1782731936083,
- },
- {
- hash: "b3d35909be61ab7fbffc6c36916d6444833385db517a302abe28cfea2b0ef544",
- createdAt: 1782731954122,
- },
- {
- hash: "1ad2b86e381f0d8ef17839a952c30f0ea6f0452367d1dcd19aa11a65d8eeecbd",
- createdAt: 1783089081925,
- },
- {
- hash: "f2c279f21375de159981c439e622e4ddcd8415384f7ca7287c3f1598bcb42fc4",
- createdAt: 1783613113217,
- },
- {
- hash: "38e26a4313f207b04bb1646f6a303c0f216ad687062d16100e4ca01db5fa74c2",
- createdAt: 1785152117830,
- },
- {
- hash: "8a65ae9a4fe4c7933d0d40182dab6da9654c8cb0f1275a8ebb43e1e0f8719256",
- createdAt: 1785169071234,
- },
-] as const;
-
-const sql = postgres(databaseUrl, { max: 1 });
-
-try {
- const [schemaState] = await sql<{
- form_tables: number;
- allowed_roles: boolean;
- denied_roles: boolean;
- cdn_table: boolean;
- image_cdn_id: boolean;
- allow_multiple_submissions: boolean;
- image_answer_mode: boolean;
- submitted_at_index: boolean;
- answer_image_fk: boolean;
- unique_submission_index: boolean;
- legacy_public_tables: boolean;
- }[]>`
- select
- (
- select count(*)::integer
- from information_schema.tables
- where table_schema = 'form'
- and table_name in ('answer', 'form', 'question', 'submission')
- ) as form_tables,
- exists (
- select 1 from information_schema.columns
- where table_schema = 'form'
- and table_name = 'form'
- and column_name = 'allowed_roles'
- ) as allowed_roles,
- exists (
- select 1 from information_schema.columns
- where table_schema = 'form'
- and table_name = 'form'
- and column_name = 'denied_roles'
- ) as denied_roles,
- to_regclass('form.cdn') is not null as cdn_table,
- exists (
- select 1 from information_schema.columns
- where table_schema = 'form'
- and table_name = 'answer'
- and column_name = 'image_cdn_id'
- ) as image_cdn_id,
- exists (
- select 1 from information_schema.columns
- where table_schema = 'form'
- and table_name = 'form'
- and column_name = 'allow_multiple_submissions'
- ) as allow_multiple_submissions,
- exists (
- select 1 from information_schema.columns
- where table_schema = 'form'
- and table_name = 'question'
- and column_name = 'image_answer_mode'
- ) as image_answer_mode,
- to_regclass('form.submission_form_submitted_at_id_idx') is not null
- as submitted_at_index,
- exists (
- select 1
- from information_schema.table_constraints
- where constraint_schema = 'form'
- and table_name = 'answer'
- and constraint_name = 'answer_image_cdn_id_cdn_id_fk'
- ) as answer_image_fk,
- to_regclass('form.submission_form_user_discord_id_unique') is not null
- as unique_submission_index,
- exists (
- select 1
- from information_schema.tables
- where table_schema = 'public'
- and table_name in ('answer', 'form', 'question', 'submission')
- ) as legacy_public_tables
- `;
-
- const matchesExpectedSchema =
- schemaState?.form_tables === 4 &&
- schemaState.allowed_roles &&
- schemaState.denied_roles &&
- schemaState.cdn_table &&
- schemaState.image_cdn_id &&
- schemaState.allow_multiple_submissions &&
- schemaState.image_answer_mode &&
- schemaState.submitted_at_index &&
- schemaState.answer_image_fk &&
- !schemaState.unique_submission_index &&
- !schemaState.legacy_public_tables;
-
- if (!matchesExpectedSchema) {
- throw new Error(
- "The database does not match the expected schema through migration 0013. Refusing to change the migration ledger."
- );
- }
-
- await sql.begin(async (transaction) => {
- await transaction`lock table drizzle.__drizzle_migrations in exclusive mode`;
-
- for (const migration of expectedMigrations) {
- await transaction`
- insert into drizzle.__drizzle_migrations (hash, created_at)
- select ${migration.hash}, ${migration.createdAt}
- where not exists (
- select 1
- from drizzle.__drizzle_migrations
- where hash = ${migration.hash}
- )
- `;
- await transaction`
- update drizzle.__drizzle_migrations
- set created_at = ${migration.createdAt}
- where hash = ${migration.hash}
- `;
- }
- });
-
- console.log("Reconciled the Drizzle migration ledger through 0013.");
-} finally {
- await sql.end();
-}