feat: redesign match play and add broadcast tooling

This commit is contained in:
2026-09-29 02:52:56 +00:00
parent f417a831f7
commit 9b9aafefc1
348 changed files with 31588 additions and 11094 deletions

View File

@@ -1,15 +1,37 @@
// src/lib/server/db/auth.schema.ts
import { relations } from 'drizzle-orm';
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
import {
pgTable,
text,
timestamp,
boolean,
index,
integer,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { jsonb } from './jsonb';
import type { ConnectionProfile } from '$lib/auth-identity';
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
// Only actual contact addresses belong here. OAuth identity lives in account.
email: text('contact_email').unique(),
// Better Auth's required email field is mapped to this private compatibility field.
authEmail: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
// ── Staff role: 'player' | 'referee' | 'admin' ──
role: text('role').default('player').notNull(),
rootAdmin: boolean('root_admin').default(false).notNull(),
mergedIntoUserId: text('merged_into_user_id'),
osuId: integer('osu_id'),
countryCode: text('country_code'),
discordId: text('discord_id'),
bannedAt: timestamp('banned_at', { withTimezone: true }),
timezone: text('timezone'),
/** Local hours (0–23) the player can play. Empty means no preference. */
availableHours: jsonb('available_hours').$type<number[]>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
@@ -52,12 +74,17 @@ export const account = pgTable(
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
profile: jsonb('profile').$type<ConnectionProfile>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => new Date())
.notNull()
},
(table) => [index('account_userId_idx').on(table.userId)]
(table) => [
index('account_userId_idx').on(table.userId),
uniqueIndex('account_provider_identity_idx').on(table.providerId, table.accountId),
uniqueIndex('account_user_provider_idx').on(table.userId, table.providerId)
]
);
export const verification = pgTable(

View File

@@ -1,48 +1,29 @@
import * as schema from './schema';
import { building } from '$app/environment';
import { SQL } from 'bun';
import { drizzle } from 'drizzle-orm/bun-sql';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { metrics } from '$lib/server/metrics';
const SLOW_QUERY_THRESHOLD_MS = 100;
import { singleton } from '$lib/server/singleton';
function createDb() {
if (building) return null!;
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
return drizzle(env.DATABASE_URL, {
const databaseUrl = env.DATABASE_URL || env.DB_URL;
if (!databaseUrl) throw new Error('DATABASE_URL or DB_URL is not set');
// One pool per process: the shared Postgres server allows 100 connections
// across every app and environment on the VM.
const client = singleton('bun-sql', () => new SQL(databaseUrl, { max: 8, idleTimeout: 60 }));
return drizzle({
client,
schema,
logger: {
logQuery(query: string, params: unknown[]) {
log.db.debug({ query: query.slice(0, 300) }, 'Query');
}
}
logger:
env.LOG_SQL === '1'
? { logQuery: (query) => log.db.debug({ query: query.slice(0, 300) }, 'query') }
: false
});
}
export const db = createDb();
/**
* Wraps a database operation with timing. Logs slow queries (>100ms)
* and updates metrics counters.
*
* Usage:
* const users = await timedQuery('getUsers', () => db.query.user.findMany());
*/
export async function timedQuery<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
const result = await fn();
const duration = Math.round(performance.now() - start);
metrics.totalQueries++;
metrics.totalQueryTime += duration;
if (duration > SLOW_QUERY_THRESHOLD_MS) {
log.db.warn({ label, duration }, 'Slow query');
metrics.slowQueries++;
}
return result;
} catch (err) {
log.db.error({ err, label }, 'Query failed');
throw err;
}
}
export type Database = typeof db;
/** A transaction or the root database, for helpers that work in either. */
export type Tx = Parameters<Parameters<Database['transaction']>[0]>[0] | Database;

View File

@@ -0,0 +1,14 @@
import { customType } from 'drizzle-orm/pg-core';
/**
* jsonb for Bun.SQL. Drizzle's own jsonb stringifies values first, and Bun.SQL
* then stores that string as a JSON string instead of an object. Bun.SQL
* encodes objects and arrays itself, so values pass through untouched.
* Rows written the double-encoded way still read back as objects.
*/
export const jsonb = customType<{ data: unknown; driverData: unknown }>({
dataType: () => 'jsonb',
toDriver: (value) => value,
fromDriver: (value) =>
typeof value === 'string' && /^[[{]/.test(value) ? JSON.parse(value) : value
});

View File

@@ -5,12 +5,12 @@ import {
uuid,
boolean,
timestamp,
jsonb,
index,
uniqueIndex,
real
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { jsonb } from './jsonb';
export * from './auth.schema';
import { user } from './auth.schema';
@@ -25,6 +25,19 @@ export const team = pgTable('team', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
});
export const teamRating = pgTable('team_rating', {
teamId: uuid('team_id')
.primaryKey()
.references(() => team.id, { onDelete: 'cascade' }),
/** OpenSkill mu, rounded; the rating the site shows. */
elo: integer('elo').default(1000).notNull(),
/** OpenSkill uncertainty. Null for ratings from before OpenSkill; see sigmaFor. */
sigma: real('sigma'),
wins: integer('wins').default(0).notNull(),
losses: integer('losses').default(0).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
// ── Team Members ────────────────────────────────────────────────────────
export const teamMember = pgTable('team_member', {
id: uuid('id').primaryKey().defaultRandom(),
@@ -42,7 +55,10 @@ export const mappool = pgTable('mappool', {
name: text('name').notNull(),
createdBy: text('created_by'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
verifiedAt: timestamp('verified_at', { withTimezone: true })
verifiedAt: timestamp('verified_at', { withTimezone: true }),
/** Where this pool came from: a Vash quick-match pool, or another tournament. */
origin: text('origin'),
rating: real('rating')
});
// ── Mappool Slots ───────────────────────────────────────────────────────
@@ -85,7 +101,17 @@ export const match = pgTable(
finishedAt: timestamp('finished_at', { withTimezone: true }),
winnerId: uuid('winner_id').references(() => team.id),
createdBy: text('created_by'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
/** osu for now. Other games keep their own procedure behind the same match row. */
game: text('game').default('osu').notNull(),
kind: text('kind').default('custom').notNull(),
procedure: jsonb('procedure'),
deadlineAt: timestamp('deadline_at', { withTimezone: true }),
eloApplied: boolean('elo_applied').default(false).notNull(),
/** Bumped on every procedure write; a stale writer fails instead of overwriting. */
revision: integer('revision').default(0).notNull(),
/** Which osu! IRC account hosts the lobby, so a restart can rejoin it. */
ircAccount: text('irc_account')
},
(t) => [index('idx_match_state').on(t.state), index('idx_match_created_at').on(t.createdAt)]
);
@@ -186,20 +212,25 @@ export const matchQueue = pgTable(
.notNull()
.references(() => team.id),
elo: integer('elo').default(1000).notNull(),
format: integer('format').default(1).notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [uniqueIndex('idx_queue_user').on(t.userId), index('idx_queue_elo').on(t.elo)]
);
// ── Player ELO ──────────────────────────────────────────────────────────
// ── Player rating ──────────────────────────────────────────────────────────
export const playerRating = pgTable('player_rating', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().unique(),
/** OpenSkill mu, rounded; the rating the site shows. */
elo: integer('elo').default(1000).notNull(),
/** OpenSkill uncertainty. Null for ratings from before OpenSkill; see sigmaFor. */
sigma: real('sigma'),
wins: integer('wins').default(0).notNull(),
losses: integer('losses').default(0).notNull(),
initialElo: integer('initial_elo'),
osuRankAtSeed: integer('osu_rank_at_seed'),
provisional: boolean('provisional').default(true).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
@@ -267,6 +298,15 @@ export const tournament = pgTable(
config: jsonb('config').notNull(),
maxSlots: integer('max_slots').notNull(),
bannerUrl: text('banner_url'),
// ── Branding: tournaments look like their own event, not a generic page ──
tagline: text('tagline'),
iconUrl: text('icon_url'),
/** Hex color used for accents on the tournament's pages. */
accentColor: text('accent_color'),
/** The osu! forum post, in osu! BBCode. */
forumPost: text('forum_post'),
links: jsonb('links').$type<{ label: string; url: string }[]>(),
prizes: jsonb('prizes').$type<{ place: string; prize: string }[]>(),
createdBy: text('created_by').notNull(),
registrationOpenAt: timestamp('registration_open_at', { withTimezone: true }),
registrationCloseAt: timestamp('registration_close_at', { withTimezone: true }),
@@ -582,6 +622,160 @@ export const tournamentGroupEntryRelations = relations(tournamentGroupEntry, ({
team: one(team, { fields: [tournamentGroupEntry.teamId], references: [team.id] })
}));
export const matchEvent = pgTable(
'match_event',
{
id: uuid('id').primaryKey().defaultRandom(),
matchId: uuid('match_id')
.notNull()
.references(() => match.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
detail: text('detail'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [index('idx_match_event_match').on(t.matchId, t.createdAt)]
);
/**
* A spectator's call on a match: who wins, and optionally the exact score.
* Tournament matches are keyed by their bracket slot, so pick'ems work before
* the match itself exists; other matches by the match.
*/
export const prediction = pgTable(
'prediction',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
matchId: uuid('match_id').references(() => match.id, { onDelete: 'cascade' }),
tournamentMatchId: uuid('tournament_match_id').references(() => tournamentMatch.id, {
onDelete: 'cascade'
}),
winnerTeamId: uuid('winner_team_id')
.notNull()
.references(() => team.id, { onDelete: 'cascade' }),
/** Exact score, winner's maps first. Optional. */
winnerScore: integer('winner_score'),
loserScore: integer('loser_score'),
/** Null until the match is decided. */
points: integer('points'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
uniqueIndex('idx_prediction_user_match').on(t.userId, t.matchId),
uniqueIndex('idx_prediction_user_tmatch').on(t.userId, t.tournamentMatchId),
index('idx_prediction_match').on(t.matchId),
index('idx_prediction_tmatch').on(t.tournamentMatchId)
]
);
/**
* A stream: what it shows (one match, a tournament's matches, or whatever is
* most worth watching), and how (the layout). Overlays and streamer agents
* connect to a channel.
*/
export const broadcastChannel = pgTable('broadcast_channel', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
/** auto | match | tournament */
mode: text('mode').default('auto').notNull(),
matchId: uuid('match_id').references(() => match.id, { onDelete: 'set null' }),
tournamentId: uuid('tournament_id').references(() => tournament.id, { onDelete: 'cascade' }),
layout: jsonb('layout').notNull(),
/** SHA-256 of the token a streamer agent signs in with. */
agentTokenHash: text('agent_token_hash'),
/** landscape (1920×1080) or portrait (1080×1920, for TikTok and Instagram) */
format: text('format').default('landscape').notNull(),
/** 720p30 | 1080p30 | 1080p60 */
quality: text('quality').default('1080p30').notNull(),
/** Whether the channel should be on air: the stream container follows this switch. */
live: boolean('live').default(false).notNull(),
/** Keep every broadcast as a video in storage, watchable on the site. */
record: boolean('record').default(true).notNull(),
createdBy: text('created_by'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
/** Where a channel streams to: a Twitch or YouTube channel, Facebook, X, Instagram, TikTok, or any RTMP server. */
export const broadcastDestination = pgTable(
'broadcast_destination',
{
id: uuid('id').primaryKey().defaultRandom(),
channelId: uuid('channel_id')
.notNull()
.references(() => broadcastChannel.id, { onDelete: 'cascade' }),
platform: text('platform').notNull(),
label: text('label').notNull(),
ingestUrl: text('ingest_url').notNull(),
/** Encrypted; see $lib/server/secrets. */
streamKey: text('stream_key').notNull(),
enabled: boolean('enabled').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [index('idx_broadcast_destination_channel').on(t.channelId)]
);
/** One time a channel was on air: its recording, and which matches it showed when. */
export const broadcastSession = pgTable(
'broadcast_session',
{
id: uuid('id').primaryKey().defaultRandom(),
channelId: uuid('channel_id')
.notNull()
.references(() => broadcastChannel.id, { onDelete: 'cascade' }),
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
endedAt: timestamp('ended_at', { withTimezone: true }),
/** Last word from the stream container, to tell a crash from a stream still running. */
seenAt: timestamp('seen_at', { withTimezone: true }).defaultNow().notNull(),
/** Storage folder with the HLS playlist and segments, when recorded. */
storagePrefix: text('storage_prefix'),
durationSec: integer('duration_sec').default(0).notNull(),
/** [{ t: seconds from the start, matchId, title }] */
chapters: jsonb('chapters')
.$type<{ t: number; matchId: string | null; title: string }[]>()
.default([])
.notNull(),
/** Each destination's last reported state. */
destinations: jsonb('destinations')
.$type<{ id: string; label: string; state: string; error: string | null }[]>()
.default([])
.notNull()
},
(t) => [index('idx_broadcast_session_channel').on(t.channelId, t.startedAt)]
);
/** Every rating movement a match caused, so a corrected result can be undone exactly. */
export const ratingChange = pgTable(
'rating_change',
{
id: uuid('id').primaryKey().defaultRandom(),
matchId: uuid('match_id')
.notNull()
.references(() => match.id, { onDelete: 'cascade' }),
/** 'player' rows point at a user id, 'team' rows at a team id. */
subject: text('subject').notNull(),
subjectId: text('subject_id').notNull(),
before: integer('before').notNull(),
after: integer('after').notNull(),
sigmaBefore: real('sigma_before'),
sigmaAfter: real('sigma_after'),
won: boolean('won').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_rating_change_match').on(t.matchId),
index('idx_rating_change_subject').on(t.subjectId)
]
);
export const systemState = pgTable('system_state', {
id: text('id').primaryKey(),
draining: boolean('draining').default(false).notNull(),
reason: text('reason'),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
// ── Profile Comments ────────────────────────────────────────────────────
export const profileComment = pgTable(
'profile_comment',