Skip to content

Database Overview

One PostgreSQL database, accessed exclusively through Prisma via the shared @playpals/db package. Every service reads and writes the same schema.

At a glance

EnginePostgreSQL 15 (managed in production, postgres:15.4 locally)
ORMPrisma 7 with the @prisma/adapter-pg driver adapter
Schemapackages/database/prisma/schema.prisma
Generated clientpackages/database/generated/prisma (git-ignored)
Models51
Enums2 — LedgerType, NotificationType
Migrations21
ID strategycuid() on every model

Documentation elsewhere says MySQL

CLAUDE.md and parts of docs/infrastructure.md describe the database as MySQL. That is stale — the schema declares provider = "postgresql" and both the local Compose stack and the Terraform configuration provision PostgreSQL.

Domain map

Rendering diagram…

Models group into eight domains:

DomainModels
IdentityUser, AuthSession, Block, UserAsset, Badge, UserBadge, CreatorProfile, VerificationAsset
ContentPost, Media, Variant, Comment, Reaction, Mention, PostImpression, Report
MessagingConversation, ConversationUser, ConversationMessage, ConversationReadState, Channel, ChannelMember, ChannelRoom, ChannelMessage, ChannelRoomParticipant, ChannelRoomReadState
LiveLiveStream, LiveStreamMessage
SessionsSession, Availability
CommerceProduct, FeaturedProduct, Order, OrderItem, Inventory, Wallet, Ledger, Payout
SubscriptionsSubscription, CreatorSubscription, RevenueCatEvent
DiscoveryGame, UserGame, Trending, SearchLog, Sponsor, Link, Notification, NotificationPreference, PushNotificationToken

Each is described in Domain Models.

Conventions

IDsString @id @default(cuid()) everywhere. Collision-resistant, sortable by creation, and safe to expose in URLs.

TimestampscreatedAt DateTime @default(now()) and updatedAt DateTime @updatedAt on nearly every model.

Money is integer cents. monthlyPrice, Wallet.balance, Ledger.usdDelta, Payout.amount, Product.price — all cents, never floats. Play Coins are whole units in coinBalance / coinDelta. Use the helpers in apps/api/src/lib/money.ts; never introduce a float.

Enums are used sparingly. Only LedgerType and NotificationType are real Postgres enums. Everything else — Post.status, Media.status, Session.status, ChannelRoom.visibility, User.userType — is a String with the allowed values in a trailing comment.

String status columns are unenforced

prisma
status String @default("scheduled") // scheduled | active | completed | cancelled

Nothing stops a typo from being written. Converting these to enums would catch a class of bug at the database level; until then, treat the comment as the contract and centralise the allowed values in code.

Naming is inconsistent on User. That model uses snake_case (display_name, first_name, birthdate) while every other model uses camelCase. Historical; follow the surrounding model rather than normalising it in an unrelated change.

Indexing

Well-indexed for the read patterns that matter:

PatternIndex
Feed by authorPost @@index([userId])
Feed by recencyPost @@index([createdAt])
Comment threadsComment @@index([postId])
Follower/following listsFollow @@index([followerId]), @@index([followingId])
Notification inboxNotification @@index([recipientId, createdAt])
Unread badgeNotification @@index([recipientId, readAt])
Creator's active subscribersCreatorSubscription @@index([creatorId, status])
A user's sessions by statusSession @@index([playpalId, status]), @@index([purchaserId, status])
Ledger by wallet and timeLedger @@index([walletId, createdAt])

Uniqueness constraints worth knowing

ConstraintPrevents
Reaction @@unique([userId, postId, commentId, kind])Double-liking
Follow @@unique([followerId, followingId])Duplicate follows
PostImpression @@unique([userId, postId])Inflated view counts
CreatorSubscription @@unique([fanId, creatorId])Two subscriptions to the same creator
Conversation.dmKey @uniqueDuplicate DM threads between the same pair
Ledger.idempotencyKey @uniqueDouble-posting money
RevenueCatEvent.eventId (primary key)Replaying a purchase webhook
ChannelMember @@unique([userId, channelId])Duplicate memberships
Block @@unique([blockerId, blockedId])Duplicate blocks

Conversation.dmKey deserves a note: it is a deterministic key built from the two participant ids, so "open a DM with this person" is an upsert rather than a search.

Denormalised columns

Deliberate duplication for read performance:

ColumnDuplicatesWhy
User.isLiveLiveStream.statusLive badge in feeds without a join
User.activeLiveStreamIdLiveStream.idJump straight to the active stream
User.playcoin_balanceWallet.coinBalanceTwo sources of truth for the same number
User.subscriptionTierSubscription.productIdFast entitlement checks

playcoin_balance and Wallet.coinBalance can disagree

The same value lives on both User and Wallet, with nothing keeping them in sync. Whichever one a given code path reads determines what the user sees. Wallet is the one backed by the Ledger and should be treated as authoritative; User.playcoin_balance should be removed.

Cascade behaviour

Mostly default (restrict). Explicit cascades exist where an orphan makes no sense:

prisma
recipient User @relation(..., onDelete: Cascade)   // Notification
blocker   User @relation(..., onDelete: Cascade)   // Block
actor     User @relation(..., onDelete: SetNull)   // Notification — keep the row, drop the actor

Because most relations restrict, deleting a User outright will fail on foreign keys. Account deletion (DELETE /api/v1/user/account) has to unwind relations explicitly.

Next

Internal documentation — PlayPalz platform