Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { type Kysely, sql } from "kysely";
const SCHEMA_PATH = fileURLToPath(
new URL("./20260803000000-initial.sql", import.meta.url),
);
const STATEMENT_SEPARATOR = "--> statement-breakpoint";
const LAST_COLLAPSED_MIGRATION = "164-drop-tournament-sub.js";
/**
* Creates the schema as it stood when the 165 hand written migrations that came
* before it were collapsed into this one. Only ever bootstraps an empty database:
* production and any copy of it already has these tables, so there this migration
* only drops the bookkeeping table of the migration runner it replaces and records
* itself as run.
*/
export async function up(db: Kysely<any>): Promise<void> {
await assertNotBehindCollapsePoint(db);
// left behind by ley, which tracked the migrations collapsed into this one
await sql`DROP TABLE IF EXISTS "migrations"`.execute(db);
if (await hasSchema(db)) return;
await db.transaction().execute(async (trx) => {
for (const statement of schemaStatements()) {
await sql.raw(statement).execute(trx);
}
});
}
/**
* A database still carrying ley's bookkeeping table is only safe to adopt as is if
* ley ran every migration collapsed into this one. Otherwise it would silently be
* left short of tables and columns this migration assumes are already there, which
* surfaces much later as confusing "no such table" errors at query time.
*/
async function assertNotBehindCollapsePoint(db: Kysely<any>) {
const { rows: bookkeeping } = await sql<{ name: string }>`
SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'migrations'
`.execute(db);
if (bookkeeping.length === 0) return;
const { rows: applied } = await sql<{ name: string }>`
SELECT name FROM "migrations" WHERE name = ${LAST_COLLAPSED_MIGRATION}
`.execute(db);
if (applied.length > 0) return;
throw new Error(
`Database has not run "${LAST_COLLAPSED_MIGRATION}", the last of the migrations collapsed into 20260803000000-initial, so it can't be migrated any further. Replace it with an up to date copy of production.`,
);
}
async function hasSchema(db: Kysely<any>) {
const { rows } = await sql<{ name: string }>`
SELECT name FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
AND name NOT IN ('kysely_migration', 'kysely_migration_lock')
LIMIT 1
`.execute(db);
return rows.length > 0;
}
function schemaStatements() {
return fs
.readFileSync(SCHEMA_PATH, "utf8")
.split(STATEMENT_SEPARATOR)
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}

View File

@@ -0,0 +1,55 @@
import { type Kysely, sql } from "kysely";
/** Lets players report why a SendouQ match was cancelled and who caused it */
export async function up(db: Kysely<any>): Promise<void> {
// kysely does not wrap sqlite migrations in a transaction, so do it here
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("GroupMatchCancelReport")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
.addColumn("groupMatchId", "integer", (col) =>
col.notNull().references("GroupMatch.id").onDelete("cascade"),
)
.addColumn("groupId", "integer", (col) =>
col.notNull().references("Group.id").onDelete("cascade"),
)
.addColumn("authorUserId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("reason", "text", (col) => col.notNull())
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.addUniqueConstraint(
"group_match_cancel_report_group_match_id_group_id",
["groupMatchId", "groupId"],
)
// every table in this schema is strict
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createTable("GroupMatchCancelReportPlayer")
.addColumn("cancelReportId", "integer", (col) =>
col
.notNull()
.references("GroupMatchCancelReport.id")
.onDelete("cascade"),
)
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addUniqueConstraint(
"group_match_cancel_report_player_cancel_report_id_user_id",
["cancelReportId", "userId"],
)
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("group_match_cancel_report_player_user_id_idx")
.on("GroupMatchCancelReportPlayer")
.column("userId")
.execute();
});
}

View File

@@ -0,0 +1,114 @@
import { type Kysely, sql } from "kysely";
const duplicateMapping = sql`
select
dup."id" as "dupId",
(
select min(kept."id")
from "Trophy" kept
where kept."code" is null
and kept."name" = dup."name"
and kept."creatorId" is dup."creatorId"
) as "keepId"
from "Trophy" dup
where dup."code" is null
and exists (
select 1
from "Trophy" kept
where kept."code" is null
and kept."name" = dup."name"
and kept."creatorId" is dup."creatorId"
and kept."id" < dup."id"
)
`;
/**
* Persists trophy submission acceptance instead of deriving it from the approval
* count, so that raising TROPHY_APPROVALS_REQUIRED cannot put already accepted
* submissions back into the review queue. Also deletes duplicate trophies that
* re-approving an already accepted submission created, keeping the oldest one.
*/
export async function up(db: Kysely<any>): Promise<void> {
// kysely does not wrap sqlite migrations in a transaction, so do it here
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("PendingTrophy")
.addColumn("acceptedAt", "integer")
.execute();
// Repoint references at duplicates to the kept trophy before deleting, so
// the on-delete cascades cannot destroy owner rows or pending submissions.
// A duplicate is a user-submitted trophy sharing name and creator with an
// older one, which the review flow cannot legitimately produce.
await sql`
update "TrophyOwner"
set "trophyId" = m."keepId"
from (${duplicateMapping}) m
where "TrophyOwner"."trophyId" = m."dupId"
and not exists (
select 1
from "TrophyOwner" existing
where existing."tournamentId" = "TrophyOwner"."tournamentId"
and existing."userId" = "TrophyOwner"."userId"
and existing."trophyId" = m."keepId"
)
`.execute(trx);
await sql`
update "CalendarEvent"
set "trophyId" = m."keepId"
from (${duplicateMapping}) m
where "CalendarEvent"."trophyId" = m."dupId"
`.execute(trx);
await sql`
update "PendingTrophy"
set "targetTrophyId" = m."keepId"
from (${duplicateMapping}) m
where "PendingTrophy"."targetTrophyId" = m."dupId"
`.execute(trx);
await sql`
delete from "Trophy"
where "id" in (select "dupId" from (${duplicateMapping}))
`.execute(trx);
// Backfill submissions accepted under the old threshold of 2 approvals.
// A row only counts as accepted if its trophy was actually created.
await sql`
update "PendingTrophy"
set "acceptedAt" = (
select max("createdAt")
from "PendingTrophyApproval"
where "PendingTrophyApproval"."pendingTrophyId" = "PendingTrophy"."id"
)
where "declinedAt" is null
and (
select count(*)
from "PendingTrophyApproval"
where "PendingTrophyApproval"."pendingTrophyId" = "PendingTrophy"."id"
) >= 2
and (
(
"targetTrophyId" is null
and exists (
select 1
from "Trophy"
where "Trophy"."name" = "PendingTrophy"."name"
and "Trophy"."creatorId" = "PendingTrophy"."submitterUserId"
)
)
or (
"targetTrophyId" is not null
and exists (
select 1
from "Trophy"
where "Trophy"."id" = "PendingTrophy"."targetTrophyId"
and "Trophy"."name" = "PendingTrophy"."name"
and "Trophy"."model" = "PendingTrophy"."model"
)
)
)
`.execute(trx);
});
}

View File

@@ -0,0 +1,12 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("TournamentTeamMember")
.addColumn("isOrganizerAdded", "integer", (col) =>
col.notNull().defaultTo(sql`0`),
)
.execute();
});
}

View File

@@ -0,0 +1,10 @@
import type { Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("User")
.addColumn("tournamentName", "text")
.execute();
});
}

View File

@@ -0,0 +1,7 @@
import type { Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema.alterTable("GroupMember").dropColumn("role").execute();
});
}

View File

@@ -0,0 +1,40 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("GroupSuggestion")
.addColumn("suggesterGroupId", "integer", (col) =>
col.notNull().references("Group.id").onDelete("cascade"),
)
.addColumn("targetGroupId", "integer", (col) =>
col.notNull().references("Group.id").onDelete("cascade"),
)
.addColumn("createdByUserId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.addUniqueConstraint(
"group_suggestion_suggester_group_id_target_group_id",
["suggesterGroupId", "targetGroupId"],
)
// every table in this schema is strict
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("group_suggestion_target_group_id")
.on("GroupSuggestion")
.column("targetGroupId")
.execute();
await trx.schema
.alterTable("GroupLike")
.addColumn("createdByUserId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.execute();
});
}

View File

@@ -0,0 +1,58 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("GroupReadyCheck")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("alphaGroupId", "integer", (col) =>
col.notNull().references("Group.id").onDelete("cascade"),
)
.addColumn("bravoGroupId", "integer", (col) =>
col.notNull().references("Group.id").onDelete("cascade"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
// every table in this schema is strict
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("group_ready_check_alpha_group_id")
.on("GroupReadyCheck")
.column("alphaGroupId")
.unique()
.execute();
await trx.schema
.createIndex("group_ready_check_bravo_group_id")
.on("GroupReadyCheck")
.column("bravoGroupId")
.unique()
.execute();
await trx.schema
.createTable("GroupReadyCheckConfirmation")
.addColumn("readyCheckId", "integer", (col) =>
col.notNull().references("GroupReadyCheck.id").onDelete("cascade"),
)
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.addUniqueConstraint(
"group_ready_check_confirmation_ready_check_id_user_id",
["readyCheckId", "userId"],
)
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.alterTable("GroupMember")
.addColumn("missedReadyCheckAt", "integer")
.execute();
});
}

View File

@@ -0,0 +1,95 @@
import { type Kysely, sql } from "kysely";
/** Tables for scanner-ingested matches and their links to reported game results */
export async function up(db: Kysely<any>): Promise<void> {
// kysely does not wrap sqlite migrations in a transaction, so do it here
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("IngestedMatch")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("povUserId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.addColumn("submitterUserId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.addColumn("playedAt", "integer")
.addColumn("data", "text", (col) => col.notNull())
.addColumn("matchHash", "text", (col) => col.unique().notNull())
.addColumn("tournamentIdHint", "integer", (col) =>
col.references("Tournament.id").onDelete("set null"),
)
.addColumn("groupMatchIdHint", "integer", (col) =>
col.references("GroupMatch.id").onDelete("set null"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
// every table in this schema is strict
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("ingested_match_pov_user_id_played_at")
.on("IngestedMatch")
.columns(["povUserId", "playedAt"])
.execute();
await trx.schema
.createIndex("ingested_match_tournament_id_hint")
.on("IngestedMatch")
.column("tournamentIdHint")
.execute();
await trx.schema
.createIndex("ingested_match_group_match_id_hint")
.on("IngestedMatch")
.column("groupMatchIdHint")
.execute();
await trx.schema
.createTable("IngestedMatchLink")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("ingestedMatchId", "integer", (col) =>
col
.notNull()
.unique()
.references("IngestedMatch.id")
.onDelete("cascade"),
)
.addColumn("tournamentMatchGameResultId", "integer", (col) =>
col.references("TournamentMatchGameResult.id").onDelete("cascade"),
)
.addColumn("groupMatchMapId", "integer", (col) =>
col.references("GroupMatchMap.id").onDelete("cascade"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.addCheckConstraint(
"ingested_match_link_one_target",
sql`("tournamentMatchGameResultId" is not null) + ("groupMatchMapId" is not null) = 1`,
)
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("ingested_match_link_tournament_match_game_result_id")
.on("IngestedMatchLink")
.column("tournamentMatchGameResultId")
.execute();
await trx.schema
.createIndex("ingested_match_link_group_match_map_id")
.on("IngestedMatchLink")
.column("groupMatchMapId")
.execute();
// ingest context resolution prunes reported games by a createdAt window
await trx.schema
.createIndex("tournament_match_game_result_created_at")
.on("TournamentMatchGameResult")
.column("createdAt")
.execute();
});
}

View File

@@ -0,0 +1,17 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("User")
.addColumn("xpDivision", "text")
.execute();
// a self-reported peak XP was the only way to pick a division before, so it carries over
await sql`
update "User"
set "xpDivision" = iif("unverifiedPeakXP" ->> '$.takoroka' is not null, 'JPN', 'WEST')
where "unverifiedPeakXP" is not null
`.execute(trx);
});
}

View File

@@ -0,0 +1,20 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
// same browser subscribed multiple times; keep only the latest row per endpoint
await sql`
delete from "NotificationUserSubscription"
where "id" not in (
select max("id")
from "NotificationUserSubscription"
group by json_extract("subscription", '$.endpoint')
)
`.execute(trx);
await sql`
create unique index "notification_user_subscription_endpoint"
on "NotificationUserSubscription" (json_extract("subscription", '$.endpoint'))
`.execute(trx);
});
}

View File

@@ -0,0 +1,46 @@
import { type Kysely, sql } from "kysely";
/** Recently used pick-up rosters that can be reused when making a new scrim post */
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("ScrimPickupRoster")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("usedAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
// every table in this schema is strict
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("scrim_pickup_roster_user_id_used_at")
.on("ScrimPickupRoster")
.columns(["userId", "usedAt"])
.execute();
await trx.schema
.createTable("ScrimPickupRosterUser")
.addColumn("scrimPickupRosterId", "integer", (col) =>
col.notNull().references("ScrimPickupRoster.id").onDelete("cascade"),
)
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addPrimaryKeyConstraint("scrim_pickup_roster_user_pk", [
"scrimPickupRosterId",
"userId",
])
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("scrim_pickup_roster_user_user_id")
.on("ScrimPickupRosterUser")
.column("userId")
.execute();
});
}

View File

@@ -0,0 +1,35 @@
import { type Kysely, sql } from "kysely";
/** Teams that don't count for team leaderboard placements, e.g. because they want to qualify with another roster */
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("LeaderboardTeamSkip")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("season", "integer", (col) => col.notNull())
.addColumn("identifier", "text", (col) => col.notNull())
.addColumn("skippedByUserId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
// every table in this schema is strict
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("leaderboard_team_skip_season_identifier")
.on("LeaderboardTeamSkip")
.columns(["season", "identifier"])
.unique()
.execute();
// the team that the hardcoded list this table replaces held. Attributed to the
// admin, who made the call back then. Inserts nothing on databases without them
await sql`
insert into "LeaderboardTeamSkip" ("season", "identifier", "skippedByUserId")
select 5, '9403-13562-15916-38062', "User"."id" from "User" where "User"."id" = 274
`.execute(trx);
});
}