feat: redesign match play and add broadcast tooling
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
src/lib/server/db/jsonb.ts
Normal file
14
src/lib/server/db/jsonb.ts
Normal 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
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user