@playpals/db
packages/database
The Prisma schema, the generated client, and a set of query helpers. Every backend service imports this package; there is no per-service database access.
What it exports
packages/database/src/index.ts:
export { prisma } from "./client";
export * from "./error";
export * from "./queries";
export * from "../generated/prisma"; // model types, enums, Prisma namespaceSo a service gets the client, the helpers, the error types, and every generated model type from one import:
import { prisma, getUserById, NotFoundError, type User, NotificationType } from "@playpals/db";The client
src/client.ts builds a pg pool, wraps it in the Prisma driver adapter, and memoises the client on globalThis outside production so hot reload does not exhaust connections.
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;In production the connection string is parsed manually rather than passed through:
const url = new URL(dbUrl);
return new Pool({
host: url.hostname,
port: url.port ? Number(url.port) : 5432,
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database: url.pathname.slice(1),
ssl: { rejectUnauthorized: false },
});The comment in the source explains why: DigitalOcean managed Postgres presents a self-signed CA, and parsing the URL by hand stops a ?sslmode= parameter in the connection string from overriding the ssl config. rejectUnauthorized: false is the deliberate consequence — the connection is encrypted, but the certificate is not verified. Supplying DigitalOcean's CA certificate and setting rejectUnauthorized: true would be strictly better.
Query helpers
src/queries/ holds hand-written helpers for the most common access patterns, one file per domain:
comment.ts feed.ts follow.ts game.ts post.ts
product.ts reaction.ts report.ts trending.ts
pushNotificationTokens.ts user.ts verificationAsset.tsThese are a convenience, not a repository layer — services also use prisma directly. Put a helper here when the same query appears in more than one service; otherwise keep it in the service that needs it.
Errors
export class NotFoundError extends Error {
constructor(message?: string) {
super(message ?? "Not found");
this.name = "NotFoundError";
}
}Scripts
cd packages/database
pnpm db:generate # prisma generate → generated/prisma
pnpm db:migrate # prisma migrate dev — creates and applies a migration
pnpm db:deploy # prisma migrate deploy — production, applies only
pnpm db:push # push schema without a migration — local experiments only
pnpm db:seed # prisma/seed.ts
pnpm db:seed:explore # prisma/seed-explore.ts: Explore tab test data (see below)
pnpm build # tsc, then copy generated/ into dist/ and fix its importsThe build script does more than compile. copy:generated copies the generated Prisma client into dist/generated, and fix:generated-imports rewrites its import paths so the published dist/ is self-contained. That is why pnpm build is required after a schema change, not just prisma generate.
Configuration
| Variable | Purpose |
|---|---|
DATABASE_URL | PostgreSQL connection string. Required — the client throws at startup without it. |
Gotchas
generated/is git-ignored. A fresh clone will not typecheck untilpnpm db:generateruns.- Rebuild after a schema change. Services import from
dist/, sopnpm build --filter=@playpals/dbis what makes new model types visible to them. - The datasource has no
urlin the schema.schema.prismadeclares onlyprovider = "postgresql"; the connection comes from the driver adapter inclient.ts.
See Database Overview and Migrations & Seeding.
Explore test data
pnpm db:seed:explore fills the Explore tab with something realistic to look at:
- 16 creators (4 of them coaches, with badges and approved creator profiles), 20 fans, and a login:
explore-viewer@explore.playpalz.gg/Password123!. - About 80 clips over the last 6 days, across well-known games that exist in the IGDB catalog, plus photo and text posts.
- Reactions, comments, views and follows with a long tail, so trending has a clear order.
Video comes from every assets/*.mp4, used in rotation. Drop more clips in there for variety. A file already on Mux from an earlier seed is reused rather than uploaded again. Avatars and photos go to Spaces under seed/explore/.
It's additive: everyone lives under @explore.playpalz.gg. Running it again doesn't duplicate anything. Instead it moves the cohort's posts and engagement forward so the newest clip is minutes old, which keeps the 7-day trending window full. Trending caches expire within 5 minutes.
