Skip to content

Database

Overview

PostgreSQL is the single source of truth for all platform data. The schema is organized into four domains:

DomainTablesPurpose
CatalogGame, Category, Author, Build, GameCategoryGame metadata, relationships, builds
AnalyticsEvent, DailyMetric, SessionRaw events, aggregated metrics, sessions
DiscoveryExplorationQueue, GameRankExploration rotation, computed rankings
FeedbackGameFeedbackLikes and dislikes
AdminAdminUserDashboard authentication

Entity Relationship Diagram

┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Author │ │ GameCategory │ │ Category │
├──────────────┤ ├──────────────────┤ ├──────────────┤
│ id (uuid) │◄──┐ │ gameId │┌──► │ id (uuid) │
│ name │ │ │ categoryId ││ │ name │
│ slug │ │ └──────────────────┘│ │ slug │
│ createdAt │ │ │ │ iconUrl │
└──────────────┘ │ ┌──────────────────┐│ │ sortOrder │
│ │ Game ││ └──────────────┘
│ ├──────────────────┤│
└─┤ id (uuid) ├┘
│ title │
│ slug (unique) │──────────────────────┐
│ description │ │
│ instructions │ ┌──────────────┐ │
│ thumbnailUrl │ │ Build │ │
│ authorId (fk) │ ├──────────────┤ │
│ visibility │ │ id (uuid) │ │
│ state │ │ gameId (fk) │◄┘
│ broker │ │ iframeUrl │
│ brokerExternalId │ │ isLive │
│ likes │ │ label │
│ dislikes │ │ createdAt │
│ createdAt │ └──────────────┘
│ updatedAt │
└────────┬─────────┘
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ GameFeedback │ │ GameRank │ │ ExplorationQueue │
├──────────────────┤ ├──────────────┤ ├──────────────────┤
│ id (uuid) │ │ id (uuid) │ │ id (uuid) │
│ gameId (fk) │ │ gameId (fk) │ │ gameId (fk) │
│ sessionId │ │ surface │ │ surface │
│ type (like/dis) │ │ category │ │ categorySlug │
│ createdAt │ │ platform │ │ platform │
└──────────────────┘ │ rank │ │ position │
│ score │ │ uniquePlayers │
│ calculatedAt │ │ threshold │
└──────────────┘ │ createdAt │
└──────────────────┘
┌──────────────────┐ ┌──────────────────────────────────┐
│ Session │ │ Event │
├──────────────────┤ ├──────────────────────────────────┤
│ id (uuid) │◄────┤ id (uuid) │
│ deviceId │ │ type │
│ platform │ │ gameId (fk, nullable) │
│ createdAt │ │ categoryId (fk, nullable) │
│ lastSeenAt │ │ sessionId (fk) │
└──────────────────┘ │ deviceId (nullable) │
│ platform │
│ timestamp │
│ receivedAt │
│ context (jsonb) │
└──────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ DailyMetric │
├──────────────────────────────────────────────────────────┤
│ id (uuid) │
│ gameId (fk) │
│ date (date) │
│ platform (desktop/mobile) │
│ surface (home/category) │
│ plays, uniquePlayers, impressions, clicks │
│ pageViews, loadingEnds, totalPlaytimeMs │
│ adsShown, likes, dislikes │
│ @@unique([gameId, date, platform, surface]) │
└──────────────────────────────────────────────────────────┘

Schema Definition (Prisma)

Catalog Domain

model Game {
id String @id @default(uuid())
title String
slug String @unique
description String? @db.Text
instructions String? @db.Text
thumbnailUrl String?
authorId String
author Author @relation(fields: [authorId], references: [id])
visibility Visibility @default(DRAFT)
state GameState @default(EXPLORATION)
broker String?
brokerExternalId String?
likes Int @default(0)
dislikes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories GameCategory[]
builds Build[]
ranks GameRank[]
explorationQueue ExplorationQueue[]
feedback GameFeedback[]
events Event[]
dailyMetrics DailyMetric[]
@@unique([broker, brokerExternalId])
@@index([visibility, state])
@@index([authorId])
@@index([slug])
}
enum Visibility {
DRAFT
HIDDEN
VISIBLE
}
enum GameState {
EXPLORATION
RANKED
}
model Author {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now())
games Game[]
}
model Category {
id String @id @default(uuid())
name String @unique
slug String @unique
iconUrl String?
sortOrder Int @default(0)
games GameCategory[]
}
model GameCategory {
gameId String
categoryId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([gameId, categoryId])
}
model Build {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
iframeUrl String
isLive Boolean @default(false)
label String?
createdAt DateTime @default(now())
@@index([gameId])
}

Analytics Domain

model Session {
id String @id @default(uuid())
deviceId String
platform Platform
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
events Event[]
feedback GameFeedback[]
@@index([deviceId])
}
model Event {
id String @id @default(uuid())
type String
gameId String?
game Game? @relation(fields: [gameId], references: [id])
categoryId String?
sessionId String
session Session @relation(fields: [sessionId], references: [id])
deviceId String? // Anonymous device fingerprint (from localStorage UUID)
platform Platform
timestamp DateTime
receivedAt DateTime @default(now())
context Json? // Surface, position, categorySlug, etc.
@@index([gameId, timestamp])
@@index([type, timestamp])
@@index([sessionId, timestamp])
@@index([gameId, type, timestamp])
@@index([deviceId])
}
model DailyMetric {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
date DateTime @db.Date
platform Platform
surface String @default("home")
plays Int @default(0)
uniquePlayers Int @default(0)
impressions Int @default(0)
clicks Int @default(0)
pageViews Int @default(0)
loadingEnds Int @default(0)
totalPlaytimeMs BigInt @default(0)
adsShown Int @default(0)
likes Int @default(0)
dislikes Int @default(0)
@@unique([gameId, date, platform, surface])
@@index([gameId, date])
@@index([date])
}
enum Platform {
DESKTOP
MOBILE
}

Discovery Domain

model GameRank {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
surface String // "home" or category slug
platform Platform
rank Int
score Float // composite score from ranking algorithm
calculatedAt DateTime @default(now())
@@unique([gameId, surface, platform])
@@index([surface, platform, rank])
}
model ExplorationQueue {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
surface String // "home" or category slug
platform Platform
position Int // queue position (lower = shown first)
uniquePlayers Int @default(0)
threshold Int @default(1000)
createdAt DateTime @default(now())
@@unique([gameId, surface, platform])
@@index([surface, platform, position])
}

Feedback Domain

model GameFeedback {
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
sessionId String
session Session @relation(fields: [sessionId], references: [id])
type FeedbackType
createdAt DateTime @default(now())
@@unique([gameId, sessionId])
@@index([gameId])
}
enum FeedbackType {
LIKE
DISLIKE
}

Admin Domain

model AdminUser {
id String @id @default(uuid())
email String @unique
passwordHash String
role AdminRole @default(EDITOR)
createdAt DateTime @default(now())
lastLoginAt DateTime?
}
enum AdminRole {
VIEWER
EDITOR
ADMIN
}

Indexes Strategy

Query Patterns & Supporting Indexes

QueryUsed ByIndex
Games by rank for a surface + platformHome page, category pagesGameRank(surface, platform, rank)
Games by authorAuthor pageGame(authorId)
Game by slugGame detail pageGame(slug) unique
Events by game in time rangeMetric aggregationEvent(gameId, timestamp)
Events by type in time rangeGlobal metricsEvent(type, timestamp)
Session events orderedPlaytime computationEvent(sessionId, timestamp)
Daily metrics for a gameAdmin dashboardDailyMetric(gameId, date)
Exploration queue orderingExploration slot fillingExplorationQueue(surface, platform, position)
Deduplication checkBroker importGame(broker, brokerExternalId) unique
Feedback uniquenessLike/dislike toggleGameFeedback(gameId, sessionId) unique

Event Table Partitioning

For MVP, use native PostgreSQL declarative partitioning by month:

CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL,
game_id UUID REFERENCES games(id),
session_id UUID NOT NULL REFERENCES sessions(id),
timestamp TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
context JSONB
) PARTITION BY RANGE (timestamp);
-- Create monthly partitions
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- Auto-create future partitions via pg_partman or a cron job

See Events Pipeline for retention policy.


Migrations

Prisma handles schema migrations:

Terminal window
# Development: apply changes directly
npx prisma db push
# Production: generate migration files
npx prisma migrate dev --name <description>
# Deploy to production
npx prisma migrate deploy

Migration Guidelines

RuleReason
Never rename columns in productionCauses downtime. Add new column, backfill, remove old
Always add indexes concurrentlyCREATE INDEX CONCURRENTLY to avoid table locks
Add columns as nullable firstAvoids full table rewrite
Test migrations against a production-size datasetCatch slow migrations before deploy

Seed Data

Development seed creates:

EntityCountNotes
Categories~15Action, Puzzle, Racing, Strategy, etc.
Authors~20Mock studio names
Games~100Mix of Draft, Hidden, Visible states
Builds1 per gameWith mock iframe URLs
Events~10KSpread across games and sessions
DailyMetrics~30 daysPre-computed for dashboard testing
Terminal window
npx tsx scripts/seed.ts

Connection Pooling

Serverless environments (Vercel) create a new database connection per function invocation. Without pooling, this quickly exhausts PostgreSQL’s connection limit.

MVP: Neon’s Built-in Pooling

Neon provides a pooled connection string out of the box using PgBouncer:

# Direct connection (for migrations)
DATABASE_URL="postgresql://user:pass@ep-xxx.neon.tech/playupi?sslmode=require"
# Pooled connection (for application queries)
DATABASE_URL_POOLED="postgresql://user:pass@ep-xxx-pooler.neon.tech/playupi?sslmode=require"

Prisma configuration:

datasource db {
provider = "postgresql"
url = env("DATABASE_URL_POOLED") // Pooled for queries
directUrl = env("DATABASE_URL") // Direct for migrations
}

Growth+: PgBouncer / Supavisor

When running on dedicated PostgreSQL, add a connection pooler (PgBouncer or Supavisor) in front of the database. Target pool size: 20-50 connections per API instance.


Backup & Recovery

PhaseStrategy
MVPManaged database provider handles backups (Neon: 7-day PITR, Supabase: daily snapshots)
GrowthDaily pg_dump to S3/R2 + managed provider backups
ScaleContinuous WAL archiving to S3, PITR to any second