From 422567272c44de2b3b846b3213c4e62912b21dcc Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 1 Aug 2026 14:38:42 +0200 Subject: [PATCH] chore(db): remove Prisma facade and drop prisma:generate from CI Co-authored-by: Cursor --- .gitea/workflows/ci.yaml | 39 +- scripts/jobs-worker.ts | 13 +- scripts/migrate-aes-cbc-to-gcm.ts | 29 +- src/actions/set-trade-lock.test.ts | 6 +- src/app/(site)/news/[slug]/page.tsx | 9 +- src/lib/ci-workflow-contract.test.ts | 3 +- src/lib/permissions.ts | 34 +- src/lib/prisma-cache.ts | 75 -- .../prisma-facade-include-contract.test.ts | 60 - src/lib/prisma-facade-relations.test.ts | 50 - src/lib/prisma-facade.ts | 1094 ----------------- src/lib/prisma.ts | 6 - src/lib/services/audit.test.ts | 99 +- src/lib/services/audit.ts | 65 +- src/lib/services/webhook.test.ts | 17 +- src/lib/services/webhook.ts | 11 +- src/lib/staff-smoke-contract.test.ts | 2 +- 17 files changed, 182 insertions(+), 1430 deletions(-) delete mode 100644 src/lib/prisma-cache.ts delete mode 100644 src/lib/prisma-facade-include-contract.test.ts delete mode 100644 src/lib/prisma-facade-relations.test.ts delete mode 100644 src/lib/prisma-facade.ts delete mode 100644 src/lib/prisma.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index cdef79df..000ee30c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -43,9 +43,7 @@ jobs: export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1" export BCRYPT_ROUNDS=4 pnpm install --frozen-lockfile - # Generate Prisma type stubs for facade type-checking (dev only). - pnpm prisma:generate - # Run lint + typecheck + tests on the Drizzle-backed codebase. + # Types come from the committed Drizzle schema (src/db/schema.ts); no prisma:generate. pnpm biome:lint pnpm typecheck pnpm test @@ -141,11 +139,7 @@ jobs: echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}" pnpm install --frozen-lockfile - # Generate Prisma type stubs (for facade type-checking) — no DB connection needed. - # Drizzle schema (src/db/schema.ts) is committed and does not require generation. - export DATABASE_URL="mysql://placeholder:please@localhost/placeholder" - pnpm prisma:generate - unset DATABASE_URL + # Types come from the committed Drizzle schema (src/db/schema.ts); no prisma:generate. export BCRYPT_ROUNDS=4 pnpm typecheck # Validate production env (AUTH_SECRET, DATABASE_URL, …) during build. @@ -205,9 +199,6 @@ jobs: rm -rf node_modules mv "${STAGE}/node_modules" node_modules - # Regenerate Prisma type stubs into live src/generated/ (gitignored build artifact). - pnpm prisma:generate - sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true # Next.js prefers an already-set process PORT over .env. PM2 may still @@ -301,10 +292,7 @@ jobs: export NODE_ENV=production export SKIP_ENV_VALIDATION=1 pnpm install --frozen-lockfile - # prisma generate only needs a resolvable URL — no live DB connection. - export DATABASE_URL="mysql://placeholder:please@localhost/placeholder" - pnpm prisma:generate - unset DATABASE_URL + # Types come from the committed Drizzle schema (src/db/schema.ts); no prisma:generate. # Tag releases must apply CMS SQL migrations against the live DB # (same path as push-to-main deploy), using the production .env. LIVE="/var/www/atom-nexst" @@ -416,21 +404,14 @@ jobs: echo "" echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options." echo "" - echo "### 4. Generate Prisma Type Stubs (Dev Only)" - echo '```bash' - echo "pnpm prisma:generate" - echo '```' - echo "" - echo "Generates TypeScript types in src/generated/prisma/ for the Prisma compatibility facade (types only — no runtime Prisma engine in production). New code should use Drizzle ORM directly via '@/lib/db'." - echo "" - echo "### 5. Run CMS Migrations" + echo "### 4. Run CMS Migrations" echo '```bash' echo "pnpm db:migrate" echo '```' echo "" - echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status." + echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status. Runtime types come from the committed Drizzle schema (src/db/schema.ts) via '@/lib/db'." echo "" - echo "### 6. Polaris Emulator" + echo "### 5. Polaris Emulator" echo "" echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):" echo '```bash' @@ -444,7 +425,7 @@ jobs: echo "./update-Nitrov3.sh" echo '```' echo "" - echo "### 7. Nitro V3 & Renderer" + echo "### 6. Nitro V3 & Renderer" echo "" echo "Clone both Nitro repos and build the client:" echo '```bash' @@ -456,14 +437,14 @@ jobs: echo "" echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)." echo "" - echo "### 8. Catalogus (catalog & gamedata)" + echo "### 7. Catalogus (catalog & gamedata)" echo "" echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:" echo '```bash' echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus" echo '```' echo "" - echo "### 9. Build & Start the CMS" + echo "### 8. Build & Start the CMS" echo '```bash' echo "# Development (hot reload)" echo "pnpm dev" @@ -474,7 +455,7 @@ jobs: echo "" echo "Open http://localhost:3000 in your browser." echo "" - echo "### 10. First Login" + echo "### 9. First Login" echo "" echo "1. Register at /register, or log in with an existing emulator account." echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';" diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index a8456e2f..850b14ce 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -1,8 +1,9 @@ import * as Sentry from "@sentry/nextjs"; import { Cron } from "croner"; +import { lt } from "drizzle-orm"; import { env } from "../src/env"; +import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db"; import { logger } from "../src/lib/logger"; -import { prisma } from "../src/lib/prisma"; function initWorkerSentry(): void { const dsn = process.env.SENTRY_DSN; @@ -72,9 +73,9 @@ async function backupEmulatorJar(): Promise { async function cleanupOldLogs(): Promise { try { const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - await prisma.websiteLoginLogs.deleteMany({ - where: { createdAt: { lt: cutoff } }, - }); + await db + .delete(WebsiteLoginLogs) + .where(lt(WebsiteLoginLogs.createdAt, cutoff)); logger.info("Cleaned up login logs older than 30 days", { module: "jobs" }); } catch (err) { captureWorkerError(err, "Log cleanup failed"); @@ -84,9 +85,7 @@ async function cleanupOldLogs(): Promise { async function cleanupOldSessions(): Promise { try { const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - await prisma.passwordReset.deleteMany({ - where: { createdAt: { lt: cutoff } }, - }); + await db.delete(PasswordReset).where(lt(PasswordReset.createdAt, cutoff)); logger.info("Cleaned up expired password reset tokens", { module: "jobs", }); diff --git a/scripts/migrate-aes-cbc-to-gcm.ts b/scripts/migrate-aes-cbc-to-gcm.ts index f79d4165..d398a551 100644 --- a/scripts/migrate-aes-cbc-to-gcm.ts +++ b/scripts/migrate-aes-cbc-to-gcm.ts @@ -16,7 +16,8 @@ import { randomBytes, timingSafeEqual, } from "node:crypto"; -import { prisma } from "../src/lib/prisma"; +import { eq, isNotNull } from "drizzle-orm"; +import { db, User } from "../src/lib/db"; function getKey(appKey: string): Buffer { const raw = appKey.startsWith("base64:") @@ -84,10 +85,10 @@ async function main() { } const key = getKey(appKey); - const users = await prisma.user.findMany({ - where: { twoFactorSecret: { not: null } }, - select: { id: true, twoFactorSecret: true }, - }); + const users = await db + .select({ id: User.id, twoFactorSecret: User.twoFactorSecret }) + .from(User) + .where(isNotNull(User.twoFactorSecret)); console.log(`Found ${users.length} user(s) with a twoFactorSecret.`); @@ -107,10 +108,10 @@ async function main() { try { const plaintext = decryptCbc(user.twoFactorSecret, key); const reEncrypted = encryptGcm(plaintext, key); - await prisma.user.update({ - where: { id: user.id }, - data: { twoFactorSecret: reEncrypted }, - }); + await db + .update(User) + .set({ twoFactorSecret: reEncrypted }) + .where(eq(User.id, user.id)); console.log(` [OK] User ${user.id} — migrated`); migrated++; } catch (err) { @@ -125,12 +126,10 @@ async function main() { if (errors > 0) process.exit(1); } -main() - .catch((err) => { - console.error(err); - process.exit(1); - }) - .finally(() => prisma.$disconnect()); +main().catch((err) => { + console.error(err); + process.exit(1); +}); /* ---- helpers (mirrored from laravel-encrypter.ts) ---- */ diff --git a/src/actions/set-trade-lock.test.ts b/src/actions/set-trade-lock.test.ts index 002a2b40..6a4a2325 100644 --- a/src/actions/set-trade-lock.test.ts +++ b/src/actions/set-trade-lock.test.ts @@ -6,7 +6,7 @@ describe("setTradeLock drizzle + RCON contract", () => { const src = readFileSync("src/actions/bulk-users.ts", "utf8"); const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8"); - it("writes sanctions + users_settings via Drizzle, not prisma facade", () => { + it("writes sanctions + users_settings via Drizzle", () => { expect(src).toContain("@/lib/db"); expect(src).toContain("UsersSettings"); expect(src).toContain("Sanctions"); @@ -14,7 +14,7 @@ describe("setTradeLock drizzle + RCON contract", () => { expect(src).toContain("tradeLockedUntil"); expect(src).toMatch(/export async function setTradeLock/); const fn = src.slice(src.indexOf("export async function setTradeLock")); - expect(fn).not.toContain("prisma.sanctions"); + expect(fn).toContain("db."); }); it("syncs live hotel via RCON settradelock + alert + disconnect", () => { @@ -33,7 +33,7 @@ describe("admin-photos drizzle contract", () => { expect(src).toContain("@/lib/db"); expect(src).toContain("CameraWeb"); expect(src).toContain("tryRemoveLocalPhotoFile"); - expect(src).not.toContain("@/lib/prisma"); + expect(src).toContain("@/lib/db"); expect(src).toContain('revalidatePath("/photos")'); }); }); diff --git a/src/app/(site)/news/[slug]/page.tsx b/src/app/(site)/news/[slug]/page.tsx index ead062e7..0a296d89 100644 --- a/src/app/(site)/news/[slug]/page.tsx +++ b/src/app/(site)/news/[slug]/page.tsx @@ -9,6 +9,7 @@ import { toggleReaction } from "@/actions/article-reactions"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { auth } from "@/lib/auth"; +import { cachedQuery } from "@/lib/cached-db"; import { db, User, @@ -18,7 +19,6 @@ import { } from "@/lib/db"; import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; -import { cacheQuery } from "@/lib/prisma-cache"; import { sanitize } from "@/lib/sanitize"; export const revalidate = 60; @@ -105,10 +105,8 @@ export default async function ArticlePage({ const { comment, reaction, error } = await searchParams; const t = await getTranslations("pages.article"); - const article = await cacheQuery( - "websiteArticles", - "findUnique", - { slug }, + const article = await cachedQuery( + `article:slug:${slug}`, async () => { const [row] = await db .select({ @@ -127,6 +125,7 @@ export default async function ArticlePage({ .catch(() => []); return row ?? null; }, + 60, ); if (!article) notFound(); diff --git a/src/lib/ci-workflow-contract.test.ts b/src/lib/ci-workflow-contract.test.ts index a6f1225f..36599bdb 100644 --- a/src/lib/ci-workflow-contract.test.ts +++ b/src/lib/ci-workflow-contract.test.ts @@ -35,7 +35,8 @@ describe("CI workflow", () => { it("applies CMS migrations on tag release before build", () => { const release = workflow.slice(workflow.indexOf("\n release:")); expect(release).toContain("pnpm db:migrate"); - expect(release).toContain("unset DATABASE_URL"); + expect(release).not.toContain("pnpm prisma:generate"); + expect(release).toContain("src/db/schema.ts"); const migrateAt = release.indexOf("pnpm db:migrate"); const buildAt = release.indexOf("pnpm build"); expect(migrateAt).toBeGreaterThan(-1); diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 8e054c3a..de985740 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -1,3 +1,4 @@ +import { eq, sql } from "drizzle-orm"; import { unstable_cache } from "next/cache"; import { cache } from "react"; import { logAuthorizationEvent } from "./admin/authorization-events"; @@ -5,8 +6,8 @@ import { isDynamicSuperAdmin } from "./admin/authorization-policy"; import { resolveAuthorizationState } from "./admin/rank-authority"; import { auth } from "./auth"; import { sessionUserId } from "./auth/session-user"; +import { db, User } from "./db"; import { redirectSafe } from "./foundation/security"; -import { prisma } from "./prisma"; // Re-export PERMS from the standalone file (safe for client components) export { PERMS } from "./permission-slugs"; @@ -32,7 +33,7 @@ function createEmptySet(): PermissionSet { */ const getCachedPermissionSlugs = unstable_cache( async (userId: number, rank: number): Promise => { - const rows = await prisma.$queryRaw<{ slug: string }[]>` + const [result] = await db.execute<{ slug: string }>(sql` SELECT DISTINCT p.slug FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id @@ -46,7 +47,8 @@ const getCachedPermissionSlugs = unstable_cache( FROM acl_roles ar WHERE ar.slug = ${`rank_${rank}`} ) - `; + `); + const rows = result as unknown as { slug: string }[]; return rows.map((r) => r.slug); }, ["user-permissions"], @@ -100,14 +102,27 @@ export const loadUserPermissions = cache(async function loadUserPermissions( const getCurrentAuthorizationState = cache(async (userId: number) => resolveAuthorizationState(userId, { - user: prisma.user, + user: { + findUnique: async ({ where }) => { + const [row] = await db + .select({ + id: User.id, + username: User.username, + rank: User.rank, + }) + .from(User) + .where(eq(User.id, where.id)) + .limit(1); + return row ?? null; + }, + }, highestRank: async () => { // Prefer the highest rank actually held by a user. Unused high IDs in // permission_ranks (common on Habbo DBs) would otherwise lock the real // owner out of super-admin / permissions management. - const rows = await prisma.$queryRaw< - { highest_rank: number | bigint | null }[] - >` + const [result] = await db.execute<{ + highest_rank: number | bigint | null; + }>(sql` SELECT COALESCE( ( SELECT MAX(u.\`rank\`) @@ -116,7 +131,10 @@ const getCurrentAuthorizationState = cache(async (userId: number) => ), (SELECT MAX(id) FROM permission_ranks) ) AS highest_rank - `; + `); + const rows = result as unknown as { + highest_rank: number | bigint | null; + }[]; return rows[0]?.highest_rank == null ? null : Number(rows[0].highest_rank); diff --git a/src/lib/prisma-cache.ts b/src/lib/prisma-cache.ts deleted file mode 100644 index da4d03d1..00000000 --- a/src/lib/prisma-cache.ts +++ /dev/null @@ -1,75 +0,0 @@ -import "server-only"; - -import { logger } from "@/lib/logger"; -import { redis } from "@/lib/redis"; - -const CACHE_TTL: Record = { - websiteArticles: 60, - websiteArticleComments: 10, - websiteHelpCenterCategories: 300, - websiteSettings: 600, - websiteBanners: 120, - websitePolls: 30, - websiteEvents: 30, - emulatorSettings: 300, - emulatorTexts: 300, - aclRoles: 600, - aclPermissions: 600, - users: 10, - ranks: 600, -}; - -const AVOID_CACHE = new Set(["count", "groupBy"]); - -function shouldCache(model: string, action: string): boolean { - if (AVOID_CACHE.has(action)) return false; - return model in CACHE_TTL; -} - -function queryKey(model: string, action: string, args: unknown): string { - const hash = JSON.stringify(args) - .replace(/["{}[\],]/g, "") - .slice(0, 120); - return `pq:${model}:${action}:${hash}`; -} - -export function cacheQuery( - model: string, - action: string, - args: unknown, - fn: () => Promise, -): Promise { - if (!shouldCache(model, action)) return fn(); - - const ttl = CACHE_TTL[model]; - const key = queryKey(model, action, args); - - return _cachedQuery(key, ttl, fn); -} - -async function _cachedQuery( - key: string, - ttl: number, - fn: () => Promise, -): Promise { - if (!redis) return fn(); - - try { - const cached = await redis.get(key); - if (cached !== null) { - return JSON.parse(cached) as T; - } - } catch { - logger.warn("Cache read failed", { key }); - } - - const result = await fn(); - - try { - await redis.setex(key, ttl, JSON.stringify(result)); - } catch { - logger.warn("Cache write failed", { key }); - } - - return result; -} diff --git a/src/lib/prisma-facade-include-contract.test.ts b/src/lib/prisma-facade-include-contract.test.ts deleted file mode 100644 index 4822b6b4..00000000 --- a/src/lib/prisma-facade-include-contract.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -describe("prisma-facade include contract", () => { - const src = readFileSync( - resolve(process.cwd(), "src/lib/prisma-facade.ts"), - "utf8", - ); - - it("groups many-relations as arrays (polls/tickets/events)", () => { - expect(src).toContain('mode: "one" | "many"'); - expect(src).toContain('mode === "many"'); - expect(src).toContain("list.push(cleaned)"); - expect(src).toContain('"many"'); - expect(src).toMatch(/queryRelationRows\([\s\S]*?"many"/); - }); - - it("recurses nested include on related rows", () => { - expect(src).toContain("opts.include && related.length > 0"); - expect(src).toContain( - "await attachIncludes(client, rel.referencedTable, related, opts.include)", - ); - }); - - it("applies relation where filters when present", () => { - expect(src).toContain("buildCondition(rel.referencedTable, opts.where)"); - }); - - it("ships poll/ticket/event drizzle relations used by admin includes", () => { - const relations = readFileSync( - resolve(process.cwd(), "src/db/relations.ts"), - "utf8", - ); - expect(relations).toContain("questions: many(WebsitePollQuestion)"); - expect(relations).toContain("votes: many(WebsitePollVote)"); - expect(relations).toContain("messages: many(WebsiteTicketMessage)"); - expect(relations).toContain("prizes: many(WebsiteEventPrize)"); - expect(relations).toContain( - "registrations: many(WebsiteEventRegistration)", - ); - }); -}); - -describe("drizzle schema generate script", () => { - it("is wired as pnpm db:schema:generate", () => { - const pkg = JSON.parse( - readFileSync(resolve(process.cwd(), "package.json"), "utf8"), - ) as { scripts: Record }; - expect(pkg.scripts["db:schema:generate"]).toContain( - "generate-drizzle-schema.mjs", - ); - expect( - readFileSync( - resolve(process.cwd(), "scripts/generate-drizzle-schema.mjs"), - "utf8", - ), - ).toContain("Usage: pnpm db:schema:generate"); - }); -}); diff --git a/src/lib/prisma-facade-relations.test.ts b/src/lib/prisma-facade-relations.test.ts deleted file mode 100644 index d5706a5b..00000000 --- a/src/lib/prisma-facade-relations.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; - -/** - * Mirrors the many-relation grouping fix in prisma-facade queryRelationRows. - * Keeps the contract local so we don't export private helpers. - */ -function groupRelationRows( - rows: Array<{ __fk: string; id: number }>, - mode: "one" | "many", -): Map { - const map = new Map(); - for (const row of rows) { - const key = String(row.__fk); - const cleaned = { id: row.id }; - if (mode === "many") { - const list = map.get(key); - if (Array.isArray(list)) list.push(cleaned); - else map.set(key, [cleaned]); - } else if (!map.has(key)) { - map.set(key, cleaned); - } - } - return map; -} - -describe("prisma-facade relation grouping", () => { - it("accumulates many rows per parent key", () => { - const map = groupRelationRows( - [ - { __fk: "1", id: 10 }, - { __fk: "1", id: 11 }, - { __fk: "2", id: 20 }, - ], - "many", - ); - expect(map.get("1")).toEqual([{ id: 10 }, { id: 11 }]); - expect(map.get("2")).toEqual([{ id: 20 }]); - }); - - it("keeps a single row for one relations", () => { - const map = groupRelationRows( - [ - { __fk: "1", id: 10 }, - { __fk: "1", id: 11 }, - ], - "one", - ); - expect(map.get("1")).toEqual({ id: 10 }); - }); -}); diff --git a/src/lib/prisma-facade.ts b/src/lib/prisma-facade.ts deleted file mode 100644 index 44ff38a9..00000000 --- a/src/lib/prisma-facade.ts +++ /dev/null @@ -1,1094 +0,0 @@ -import "server-only"; - -import { - and, - asc, - desc, - eq, - gt, - gte, - inArray, - isNotNull, - isNull, - like, - lt, - lte, - ne, - not, - notInArray, - or, - SQL, - sql, -} from "drizzle-orm"; -import { createTableRelationsHelpers, One } from "drizzle-orm/relations"; -import * as relationsDefs from "@/db/relations"; -import * as schema from "@/db/schema"; -import { db } from "@/lib/db"; - -type Client = typeof db; - -const FULL = { ...schema, ...relationsDefs } as Record; - -const COLUMNS_SYM = Symbol.for("drizzle:Columns"); - -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} - -function lowerFirst(s: string): string { - return s.charAt(0).toLowerCase() + s.slice(1); -} - -function getTable(model: string): any { - const table = FULL[capitalize(model)]; - if (!table) { - throw new Error(`[prisma] unknown model "${model}"`); - } - return table; -} - -function tableName(table: any): string { - return table[Symbol.for("drizzle:Name")] ?? "?"; -} - -function tableColumns(table: any): Record { - return table[COLUMNS_SYM] ?? table; -} - -function getRelations(table: any): Record { - const relDef = FULL[`${lowerFirst(tableName(table))}Relations`]; - if (!relDef) return {}; - return relDef.config(createTableRelationsHelpers(table)); -} - -function coerceValue(col: any, value: any): any { - if (value === null || value === undefined) return value; - if (typeof value === "object" && typeof value.toNumber === "function") { - return Number(value.toNumber()); - } - if (col?.dataType === "bigint") { - if (typeof value === "number") return BigInt(Math.trunc(value)); - if (typeof value === "string" && /^-?\d+$/.test(value)) { - return BigInt(value); - } - } - return value; -} - -function coerceArray(col: any, values: unknown[]): unknown[] { - return values.map((v) => coerceValue(col, v)); -} - -function buildFieldCondition(col: any, value: any): SQL { - if (value === null) return isNull(col); - if (typeof value !== "object") return eq(col, coerceValue(col, value)); - - const parts: SQL[] = []; - for (const [op, v] of Object.entries(value)) { - switch (op) { - case "equals": - parts.push(eq(col, coerceValue(col, v))); - break; - case "not": - if (v === null) parts.push(isNotNull(col)); - else if (typeof v === "object") - parts.push(not(buildFieldCondition(col, v))); - else parts.push(ne(col, coerceValue(col, v))); - break; - case "in": - parts.push(inArray(col, coerceArray(col, v as unknown[]))); - break; - case "notIn": - parts.push(notInArray(col, coerceArray(col, v as unknown[]))); - break; - case "lt": - parts.push(lt(col, coerceValue(col, v))); - break; - case "lte": - parts.push(lte(col, coerceValue(col, v))); - break; - case "gt": - parts.push(gt(col, coerceValue(col, v))); - break; - case "gte": - parts.push(gte(col, coerceValue(col, v))); - break; - case "contains": - parts.push(like(col, `%${v}%`)); - break; - case "startsWith": - parts.push(like(col, `${v}%`)); - break; - case "endsWith": - parts.push(like(col, `%${v}`)); - break; - case "mode": - case "search": - // MySQL collations are case-insensitive by default; full-text not used. - break; - default: - break; - } - } - return parts.length === 1 - ? parts[0] - : (and(...(parts as [SQL, ...SQL[]])) as SQL); -} - -function buildCondition(table: any, where: any): SQL | undefined { - if (where === undefined || where === null) return undefined; - const cols = tableColumns(table); - const conds: SQL[] = []; - - for (const [key, value] of Object.entries(where)) { - if (key === "AND") { - const list = Array.isArray(value) ? value : [value]; - const inner = list - .map((w: any) => buildCondition(table, w)) - .filter((w): w is SQL => Boolean(w)); - if (inner.length) conds.push(and(...(inner as [SQL, ...SQL[]])) as SQL); - } else if (key === "OR") { - const list = Array.isArray(value) ? value : [value]; - const inner = list - .map((w: any) => buildCondition(table, w)) - .filter((w): w is SQL => Boolean(w)); - if (inner.length) conds.push(or(...(inner as [SQL, ...SQL[]])) as SQL); - } else if (key === "NOT") { - const inner = Array.isArray(value) - ? and( - ...value - .map((w: any) => buildCondition(table, w)) - .filter((w): w is SQL => Boolean(w)), - ) - : buildCondition(table, value); - if (inner) conds.push(not(inner)); - } else { - const col = cols[key]; - if (!col) { - // Compound unique key, e.g. `eventId_userId`. - const parts = key.split("_"); - if (parts.length > 1 && parts.every((p) => cols[p])) { - conds.push( - and( - ...(parts.map((p) => - eq( - (cols as Record)[p], - coerceValue( - (cols as Record)[p], - (value as any)?.[p], - ), - ), - ) as [SQL, ...SQL[]]), - ) as SQL, - ); - } else { - throw new Error( - `[prisma] unknown field "${key}" on model "${tableName(table)}"`, - ); - } - continue; - } - conds.push(buildFieldCondition(col, value)); - } - } - - if (conds.length === 0) return undefined; - return conds.length === 1 ? conds[0] : and(...conds); -} - -function buildOrderBy(table: any, orderBy: any): SQL[] { - if (!orderBy) return []; - const entries = Array.isArray(orderBy) ? orderBy : [orderBy]; - const cols = tableColumns(table); - return entries.flatMap((entry: any) => { - if (typeof entry === "string") { - const col = cols[entry]; - return col ? [asc(col)] : []; - } - return Object.entries(entry).flatMap(([key, dir]) => { - if (key === "_count") return []; - const col = cols[key]; - if (!col) return []; - return dir === "desc" ? [desc(col)] : [asc(col)]; - }); - }); -} - -function projection(table: any, select: any): Record | undefined { - if (!select || typeof select !== "object") return undefined; - const cols = tableColumns(table); - const out: Record = {}; - for (const [key, enabled] of Object.entries(select)) { - if (key === "_count") continue; - if (enabled && cols[key]) out[key] = cols[key]; - } - return Object.keys(out).length ? out : undefined; -} - -function pickRow(row: any, select: any): any { - if (!select || typeof select !== "object") return row; - const out: any = {}; - for (const [key, enabled] of Object.entries(select)) { - if (enabled && key in row) out[key] = row[key]; - } - return out; -} - -interface Join { - fkCols: any[]; // columns on the *target* used to filter/group - refCols: any[]; // columns on the *source* used to match -} - -function resolveJoin(table: any, rel: any): Join { - if (rel instanceof One) { - const cfg = rel.config ?? { fields: [], references: [] }; - return { fkCols: cfg.references, refCols: cfg.fields }; - } - if (rel.config?.fields && rel.config.references) { - return { fkCols: rel.config.fields, refCols: rel.config.references }; - } - // Implicit many: find the reverse one-relation on the target. - const targetRels = getRelations(rel.referencedTable); - for (const r of Object.values(targetRels)) { - if (r instanceof One && r.referencedTable === table) { - const cfg = r.config ?? { fields: [], references: [] }; - return { fkCols: cfg.fields, refCols: cfg.references }; - } - } - throw new Error( - `[prisma] cannot resolve join for relation "${rel.fieldName}" on "${tableName(table)}"`, - ); -} - -function colKey(col: any): string { - return col.key ?? col.name; -} - -async function queryRelationRows( - client: Client, - table: any, - rel: any, - opts: any, - keyValues: unknown[], - mode: "one" | "many", -): Promise> { - const join = resolveJoin(table, rel); - const byCol = join.fkCols[0]; - const parts = [ - inArray(byCol, coerceArray(byCol, keyValues)), - buildCondition(rel.referencedTable, opts.where), - ].filter(Boolean); - const condition = parts.length === 1 ? parts[0] : and(...parts); - const sel = projection(rel.referencedTable, opts.select); - const orderBy = buildOrderBy(rel.referencedTable, opts.orderBy); - - let stmt: any = sel - ? client.select({ ...sel, __fk: byCol }).from(rel.referencedTable) - : client.select().from(rel.referencedTable); - if (condition) stmt = stmt.where(condition); - if (orderBy.length) stmt = stmt.orderBy(...orderBy); - - const rows = (await stmt) as any[]; - const map = new Map(); - for (const row of rows) { - const key = String(row.__fk ?? row[colKey(byCol)]); - const cleaned = sel ? pickRow(row, opts.select) : row; - if (mode === "many") { - const list = map.get(key); - if (Array.isArray(list)) list.push(cleaned); - else map.set(key, [cleaned]); - } else if (!map.has(key)) { - map.set(key, cleaned); - } - } - return map; -} - -function attachCounts( - rows: any[], - table: any, - rel: any, - counts: Map, -): void { - const join = resolveJoin(table, rel); - const refCol = join.refCols[0]; - for (const row of rows) { - if (!row._count) row._count = {}; - const refValue = row[colKey(refCol)]; - row._count[rel.fieldName] = - refValue == null ? 0 : (counts.get(String(refValue)) ?? 0); - } -} - -async function runCounts( - client: Client, - table: any, - rows: any[], - countSpec: Record, -): Promise { - const rels = getRelations(table); - for (const [relName, enabled] of Object.entries(countSpec)) { - if (!enabled) continue; - const rel = rels[relName]; - if (!rel) continue; - const join = resolveJoin(table, rel); - const refCol = join.refCols[0]; - const values = rows - .map((r) => r[colKey(refCol)]) - .filter((v) => v !== null && v !== undefined); - const counts = new Map(); - if (values.length > 0) { - const byCol = join.fkCols[0]; - const grouped = (await client - .select({ - __fk: byCol, - __c: sql`COUNT(*)`, - }) - .from(rel.referencedTable) - .where(inArray(byCol, coerceArray(byCol, values))) - .groupBy(byCol)) as any[]; - for (const g of grouped) counts.set(String(g.__fk), Number(g.__c)); - } - attachCounts(rows, table, rel, counts); - } -} - -async function attachIncludes( - client: Client, - table: any, - rows: any[], - includes: any, -): Promise { - if (!includes || typeof includes !== "object" || rows.length === 0) return; - const rels = getRelations(table); - - const oneJobs: Array<{ rel: any; opts: any; values: unknown[] }> = []; - const manyJobs: Array<{ rel: any; opts: any; values: unknown[] }> = []; - const countSpec: Record = {}; - - for (const [name, optsRaw] of Object.entries(includes)) { - if (name === "_count") { - if ( - optsRaw !== null && - typeof optsRaw === "object" && - (optsRaw as any).select - ) { - Object.assign(countSpec, (optsRaw as any).select); - } - continue; - } - const rel = rels[name]; - if (!rel) continue; - const opts = optsRaw === true ? {} : (optsRaw ?? {}); - const join = resolveJoin(table, rel); - const refCol = join.refCols[0]; - const values = rows - .map((r) => r[colKey(refCol)]) - .filter((v) => v !== null && v !== undefined); - if (rel instanceof One) { - oneJobs.push({ rel, opts, values }); - } else { - manyJobs.push({ rel, opts, values }); - } - } - - for (const job of oneJobs) { - const { rel, opts, values } = job; - if (values.length === 0) { - for (const row of rows) row[rel.fieldName] = null; - continue; - } - const map = await queryRelationRows( - client, - table, - rel, - opts, - values, - "one", - ); - const join = resolveJoin(table, rel); - const refCol = join.refCols[0]; - const related: any[] = []; - for (const row of rows) { - const refValue = row[colKey(refCol)]; - const hit = refValue == null ? null : (map.get(String(refValue)) ?? null); - row[rel.fieldName] = hit; - if (hit) related.push(hit); - } - if (opts.include && related.length > 0) { - await attachIncludes(client, rel.referencedTable, related, opts.include); - } - } - - for (const job of manyJobs) { - const { rel, opts, values } = job; - if (values.length === 0) { - for (const row of rows) row[rel.fieldName] = []; - continue; - } - const map = await queryRelationRows( - client, - table, - rel, - opts, - values, - "many", - ); - const join = resolveJoin(table, rel); - const refCol = join.refCols[0]; - const related: any[] = []; - for (const row of rows) { - const refValue = row[colKey(refCol)]; - const list = refValue == null ? [] : (map.get(String(refValue)) ?? []); - row[rel.fieldName] = list; - for (const item of list) related.push(item); - } - if (opts.include && related.length > 0) { - await attachIncludes(client, rel.referencedTable, related, opts.include); - } - } - - if (Object.keys(countSpec).length) { - await runCounts(client, table, rows, countSpec); - } -} - -// ---------- Query execution ---------- - -interface ReadArgs { - where?: any; - select?: any; - include?: any; - orderBy?: any; - take?: number; - skip?: number; -} - -async function runFindMany( - client: Client, - model: string, - args: ReadArgs, -): Promise { - const table = getTable(model); - const condition = buildCondition(table, args.where); - const orderBy = buildOrderBy(table, args.orderBy); - const sel = projection(table, args.select); - - let stmt: any = sel - ? client.select({ ...sel }).from(table) - : client.select().from(table); - if (condition) stmt = stmt.where(condition); - if (orderBy.length) stmt = stmt.orderBy(...orderBy); - if (args.take !== undefined) stmt = stmt.limit(args.take); - else if (args.skip) - // biome-ignore lint/correctness/noPrecisionLoss: SQL literal for BIGINT UNSIGNED max value (2^64-1 — exceeds JS number precision) - stmt = stmt.limit(18446744073709551615); - if (args.skip) stmt = stmt.offset(args.skip); - - const rows = (await stmt) as any[]; - - await attachIncludes(client, table, rows, args.include); - - if (args.select && typeof args.select === "object" && args.select._count) { - const countSpec: Record = {}; - for (const [relName, enabled] of Object.entries( - args.select._count.select ?? {}, - )) { - if (enabled) countSpec[relName] = true; - } - if (Object.keys(countSpec).length) { - await runCounts(client, table, rows, countSpec); - } - const projected = rows.map((row) => pickRow(row, args.select)); - return projected; - } - - return rows; -} - -async function runFindOne( - client: Client, - model: string, - args: ReadArgs, - orThrow: boolean, -): Promise { - const rows = await runFindMany(client, model, { ...args, take: 1 }); - if (rows.length === 0) { - if (orThrow) throw new Error(`[prisma] ${model} not found`); - return null; - } - return rows[0]; -} - -// ---------- Mutations ---------- - -function pkColumns(table: any): any[] { - const cols = tableColumns(table); - return Object.values(cols).filter((c) => c.primary); -} - -function coerceData(table: any, data: any): Record { - const cols = tableColumns(table); - const coerced: Record = {}; - for (const [key, value] of Object.entries(data ?? {})) { - coerced[key] = coerceValue(cols[key], value); - } - return coerced; -} - -async function runCreate( - client: Client, - model: string, - args: any, -): Promise { - const table = getTable(model); - const [result] = (await client - .insert(table) - .values(coerceData(table, args.data))) as any; - - const pks = pkColumns(table); - const where: any = {}; - if (pks.length === 1 && pks[0].autoIncrement) { - where[colKey(pks[0])] = result.insertId; - } else { - for (const pk of pks) { - where[colKey(pk)] = args.data?.[colKey(pk)]; - } - } - return runFindOne( - client, - model, - { where, select: args.select, include: args.include }, - false, - ); -} - -async function runUpdate( - client: Client, - model: string, - args: any, -): Promise { - const table = getTable(model); - const condition = buildCondition(table, args.where); - if (condition) { - await client - .update(table) - .set(coerceData(table, args.data)) - .where(condition); - } - return runFindOne( - client, - model, - { where: args.where, select: args.select, include: args.include }, - false, - ); -} - -async function runDelete( - client: Client, - model: string, - args: any, -): Promise { - const table = getTable(model); - const row = await runFindOne(client, model, { where: args.where }, false); - if (!row) return null; - const condition = buildCondition(table, args.where); - if (condition) { - await client.delete(table).where(condition); - } - return row; -} - -async function runDeleteMany( - client: Client, - model: string, - args: any, -): Promise<{ count: number }> { - const table = getTable(model); - const condition = buildCondition(table, args.where); - if (!condition) { - const [result] = (await client.delete(table)) as any; - return { count: Number(result.affectedRows) }; - } - const [result] = (await client.delete(table).where(condition)) as any; - return { count: Number(result.affectedRows) }; -} - -async function runUpdateMany( - client: Client, - model: string, - args: any, -): Promise<{ count: number }> { - const table = getTable(model); - const condition = buildCondition(table, args.where); - const [result] = condition - ? ((await client - .update(table) - .set(coerceData(table, args.data)) - .where(condition)) as any) - : ((await client.update(table).set(coerceData(table, args.data))) as any); - return { count: Number(result.affectedRows) }; -} - -async function runCreateMany( - client: Client, - model: string, - args: any, -): Promise<{ count: number }> { - const table = getTable(model); - const rowsData = Array.isArray(args.data) ? args.data : [args.data]; - const values = rowsData.map((data: any) => coerceData(table, data)); - const [result] = (await client.insert(table).values(values)) as any; - return { count: Number(result.affectedRows) }; -} - -async function runUpsert( - client: Client, - model: string, - args: any, -): Promise { - const existing = await runFindOne( - client, - model, - { where: args.where }, - false, - ); - if (existing) { - return runUpdate(client, model, { - where: args.where, - data: args.update, - select: args.select, - include: args.include, - }); - } - return runCreate(client, model, { - data: args.create, - select: args.select, - include: args.include, - }); -} - -async function runCount( - client: Client, - model: string, - args: any, -): Promise { - const table = getTable(model); - const condition = buildCondition(table, args.where); - let stmt: any = client.select({ __c: sql`COUNT(*)` }).from(table); - if (condition) stmt = stmt.where(condition); - const [row] = (await stmt) as any; - return Number(row?.__c ?? 0); -} - -const AGG_FNS = { - _sum: "SUM", - _min: "MIN", - _max: "MAX", - _avg: "AVG", -} as const; - -async function runAggregate( - client: Client, - model: string, - args: any, -): Promise { - const table = getTable(model); - const cols = tableColumns(table); - const condition = buildCondition(table, args.where); - - const sel: Record = {}; - const meta: Record< - string, - { agg: keyof typeof AGG_FNS; field: string; col: any } - > = {}; - for (const agg of ["_sum", "_min", "_max", "_avg"] as const) { - if (!args[agg]) continue; - for (const [field, enabled] of Object.entries(args[agg])) { - if (!enabled) continue; - const col = cols[field]; - if (!col) continue; - const alias = `__${agg}${field}`; - sel[alias] = sql`${sql.raw(AGG_FNS[agg])}(${col})`; - meta[alias] = { agg, field, col }; - } - } - - const out: Record = {}; - if (Object.keys(sel).length) { - let stmt: any = client.select(sel).from(table); - if (condition) stmt = stmt.where(condition); - if (args.take) stmt = stmt.limit(args.take); - if (args.skip) stmt = stmt.limit(Number.MAX_SAFE_INTEGER).offset(args.skip); - const [row] = (await stmt) as any; - for (const [alias, { agg, field, col }] of Object.entries(meta)) { - if (!out[agg]) out[agg] = {}; - const raw = row?.[alias]; - if (raw === null || raw === undefined) { - out[agg][field] = null; - } else if (col.dataType === "bigint") { - out[agg][field] = BigInt(raw); - } else { - out[agg][field] = Number(raw); - } - } - } - - if (args._count) { - let stmt: any = client.select({ __c: sql`COUNT(*)` }).from(table); - if (condition) stmt = stmt.where(condition); - const [row] = (await stmt) as any; - out._count = Number(row?.__c ?? 0); - } - - return out; -} - -async function runGroupBy( - client: Client, - model: string, - args: any, -): Promise { - const table = getTable(model); - const cols = tableColumns(table); - const condition = buildCondition(table, args.where); - const byCols: any[] = (args.by ?? []).map((f: string) => { - const col = cols[f]; - if (!col) - throw new Error(`[prisma] groupBy unknown field "${f}" on "${model}"`); - return col; - }); - - const sel: Record = {}; - for (const col of byCols) sel[colKey(col)] = col; - - const aggMeta: Record< - string, - Record - > = {}; - for (const agg of ["_count", "_sum", "_min", "_max", "_avg"] as const) { - const spec = args[agg]; - if (!spec) continue; - aggMeta[agg] = {}; - for (const [field, enabled] of Object.entries(spec)) { - if (!enabled) continue; - if (field === "_all") { - const alias = `__${agg}_all`; - sel[alias] = sql`COUNT(*)`; - aggMeta[agg]._all = { alias, all: true }; - continue; - } - const col = cols[field]; - if (!col) continue; - const fn = - agg === "_count" - ? "COUNT" - : agg === "_sum" - ? "SUM" - : agg === "_min" - ? "MIN" - : agg === "_max" - ? "MAX" - : "AVG"; - const alias = `__${agg}${field}`; - sel[alias] = sql`${sql.raw(fn)}(${col})`; - aggMeta[agg][field] = { alias, col, all: false }; - } - } - - let stmt: any = client.select(sel).from(table); - if (condition) stmt = stmt.where(condition); - if (byCols.length) stmt = stmt.groupBy(...byCols); - - if (args.having) { - const havings: SQL[] = []; - for (const [field, ops] of Object.entries(args.having as any)) { - for (const [agg, op] of Object.entries(ops as any)) { - if (agg !== "_count") continue; - const col = cols[field]; - const countExpr = col ? sql`COUNT(${col})` : sql`COUNT(*)`; - for (const [cmp, val] of Object.entries(op as any)) { - if (cmp === "gte") havings.push(sql`${countExpr} >= ${val}`); - else if (cmp === "gt") havings.push(sql`${countExpr} > ${val}`); - else if (cmp === "lte") havings.push(sql`${countExpr} <= ${val}`); - else if (cmp === "lt") havings.push(sql`${countExpr} < ${val}`); - else if (cmp === "equals") havings.push(sql`${countExpr} = ${val}`); - } - } - } - if (havings.length) stmt = stmt.having(...(havings as [SQL])); - } - - if (args.orderBy) { - const entries = Array.isArray(args.orderBy) ? args.orderBy : [args.orderBy]; - const orderSql: SQL[] = []; - for (const entry of entries) { - for (const [field, dir] of Object.entries(entry as any)) { - if (field === "_count") { - for (const [f, d] of Object.entries(dir as any)) { - const col = f === "_all" ? undefined : cols[f]; - const target = col ? sql`COUNT(${col})` : sql`COUNT(*)`; - orderSql.push( - d === "desc" ? desc(target as any) : asc(target as any), - ); - } - } else { - const col = cols[field]; - if (col) orderSql.push(dir === "desc" ? desc(col) : asc(col)); - } - } - } - if (orderSql.length) stmt = stmt.orderBy(...orderSql); - } - - if (args.take !== undefined) stmt = stmt.limit(args.take); - else if (args.skip) - // biome-ignore lint/correctness/noPrecisionLoss: SQL literal for BIGINT UNSIGNED max value (2^64-1 — exceeds JS number precision) - stmt = stmt.limit(18446744073709551615); - if (args.skip) stmt = stmt.offset(args.skip); - - const rows = (await stmt) as any[]; - return rows.map((row) => { - const out: any = {}; - for (const col of byCols) out[colKey(col)] = row[colKey(col)]; - for (const [agg, fields] of Object.entries(aggMeta)) { - out[agg] = {}; - for (const [field, { alias, col, all }] of Object.entries(fields)) { - const raw = row[alias]; - if (all) { - out[agg]._all = Number(raw); - } else if (agg === "_count") { - out[agg][field] = Number(raw); - } else if (col?.dataType === "bigint" && raw != null) { - out[agg][field] = BigInt(raw); - } else { - out[agg][field] = raw == null ? null : Number(raw); - } - } - } - return out; - }); -} - -// ---------- Raw queries ---------- - -async function runQuery(client: Client, chunk: SQL): Promise { - const [rows] = (await client.execute(chunk)) as any; - return rows; -} - -async function runExecute(client: Client, chunk: SQL): Promise { - const [result] = (await client.execute(chunk)) as any; - return Number(result?.affectedRows ?? 0); -} - -function toSqlChunk(strings: TemplateStringsArray, values: unknown[]): SQL { - return sql(strings, ...values); -} - -function unsafeSql(query: string, values: unknown[]): SQL { - if (values.length === 0) return sql.raw(query); - const parts = query.split("?"); - if (parts.length !== values.length + 1) { - throw new Error( - `[prisma] $queryRawUnsafe: expected ${parts.length - 1} params, got ${values.length}`, - ); - } - const chunks: SQL[] = [sql.raw(parts[0])]; - for (let i = 0; i < values.length; i++) { - const value = values[i]; - chunks.push(value instanceof SQL ? value : sql`${value}`); - chunks.push(sql.raw(parts[i + 1])); - } - return sql.join(chunks); -} - -// ---------- PrismaPromise (lazy, batchable) ---------- - -class PrismaPromise implements PromiseLike { - constructor( - private readonly runner: (client: Client) => Promise, - private readonly client: Client, - ) {} - - // biome-ignore lint/suspicious/noThenProperty: Custom thenable for $transaction chaining — routes through Drizzle at runtime - then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike) | null, - ): PromiseLike { - return this.runner(this.client).then(onFulfilled, onRejected); - } - - catch( - onRejected?: ((reason: any) => TResult | PromiseLike) | null, - ): PromiseLike { - return this.runner(this.client).catch(onRejected); - } - - finally(onFinally?: (() => void) | null): PromiseLike { - return this.runner(this.client).finally(onFinally); - } - - run(client: Client): Promise { - return this.runner(client); - } -} - -function prismaPromise( - runner: (client: Client) => Promise, - client: Client, -): PrismaPromise { - return new PrismaPromise(runner, client); -} - -// ---------- Model delegate ---------- - -function makeDelegate(model: string, client: Client) { - return { - findMany: (args: ReadArgs = {}) => - prismaPromise((c) => runFindMany(c, model, args), client), - findUnique: (args: ReadArgs) => - prismaPromise((c) => runFindOne(c, model, args, false), client), - findUniqueOrThrow: (args: ReadArgs) => - prismaPromise((c) => runFindOne(c, model, args, true), client), - findFirst: (args: ReadArgs = {}) => - prismaPromise((c) => runFindOne(c, model, args, false), client), - findFirstOrThrow: (args: ReadArgs = {}) => - prismaPromise((c) => runFindOne(c, model, args, true), client), - create: (args: any) => - prismaPromise((c) => runCreate(c, model, args), client), - createMany: (args: any) => - prismaPromise((c) => runCreateMany(c, model, args), client), - update: (args: any) => - prismaPromise((c) => runUpdate(c, model, args), client), - updateMany: (args: any) => - prismaPromise((c) => runUpdateMany(c, model, args), client), - delete: (args: any) => - prismaPromise((c) => runDelete(c, model, args), client), - deleteMany: (args: any) => - prismaPromise((c) => runDeleteMany(c, model, args), client), - upsert: (args: any) => - prismaPromise((c) => runUpsert(c, model, args), client), - count: (args: any = {}) => - prismaPromise((c) => runCount(c, model, args), client), - aggregate: (args: any = {}) => - prismaPromise((c) => runAggregate(c, model, args), client), - groupBy: (args: any) => - prismaPromise((c) => runGroupBy(c, model, args), client), - }; -} - -// ---------- Facade ---------- - -interface ModelDelegate { - findMany(args?: any): PrismaPromise; - findUnique(args: any): PrismaPromise; - findUniqueOrThrow(args: any): PrismaPromise; - findFirst(args?: any): PrismaPromise; - findFirstOrThrow(args?: any): PrismaPromise; - create(args: any): PrismaPromise; - createMany(args: any): PrismaPromise<{ count: number }>; - update(args: any): PrismaPromise; - updateMany(args: any): PrismaPromise<{ count: number }>; - delete(args: any): PrismaPromise; - deleteMany(args: any): PrismaPromise<{ count: number }>; - upsert(args: any): PrismaPromise; - count(args?: any): PrismaPromise; - aggregate(args?: any): PrismaPromise; - groupBy(args: any): PrismaPromise; -} - -interface PrismaFacadeSpecific { - $queryRaw: (...args: any[]) => PrismaPromise; - $executeRaw: (...args: any[]) => PrismaPromise; - $queryRawUnsafe: (...args: any[]) => PrismaPromise; - $executeRawUnsafe: (...args: any[]) => PrismaPromise; - $transaction: (arg: any, options?: any) => Promise; - $connect: () => Promise; - $disconnect: () => Promise; - $on: (...args: any[]) => void; - $use: (...args: any[]) => void; -} - -type PrismaFacade = PrismaFacadeSpecific & Record; - -function isSqlChunk(value: any): value is SQL { - return Boolean(value && typeof value === "object" && "queryChunks" in value); -} - -export function makePrisma(client: Client): PrismaFacade { - const methods: Record = { - $queryRaw: (strings: TemplateStringsArray | SQL, ...values: unknown[]) => { - const chunk = isSqlChunk(strings) - ? strings - : toSqlChunk(strings as TemplateStringsArray, values); - return prismaPromise((c) => runQuery(c, chunk), client); - }, - $executeRaw: ( - strings: TemplateStringsArray | SQL, - ...values: unknown[] - ) => { - const chunk = isSqlChunk(strings) - ? strings - : toSqlChunk(strings as TemplateStringsArray, values); - return prismaPromise((c) => runExecute(c, chunk), client); - }, - $queryRawUnsafe: (query: string, ...values: unknown[]) => - prismaPromise((c) => runQuery(c, unsafeSql(query, values)), client), - $executeRawUnsafe: (query: string, ...values: unknown[]) => - prismaPromise((c) => runExecute(c, unsafeSql(query, values)), client), - $transaction: (arg: any, options?: any) => { - if (typeof arg === "function") { - return client.transaction(async (tx) => { - const txClient = tx as unknown as Client; - return arg(makePrisma(txClient)); - }, options); - } - if (Array.isArray(arg)) { - return client.transaction(async (tx) => { - const txClient = tx as unknown as Client; - const results: any[] = []; - for (const op of arg) { - results.push( - op instanceof PrismaPromise - ? await op.run(txClient) - : await (op as any), - ); - } - return results; - }, options); - } - return Promise.resolve(arg); - }, - $connect: async () => {}, - $disconnect: async () => {}, - $on: () => {}, - $use: () => {}, - }; - - return new Proxy(methods, { - get(target, prop) { - if (typeof prop !== "string") return undefined; - if (prop in target) return (target as any)[prop]; - return makeDelegate(prop, client); - }, - }) as PrismaFacade; -} - -export const prisma = makePrisma(db); - -export const sqlHelper = sql; -export const rawHelper = (query: string): SQL => sql.raw(query); -export const joinHelper = (values: unknown[]): SQL => - sql.join( - values.map((v) => (v instanceof SQL ? v : sql`${v}`)), - sql`, `, - ); -export const emptyHelper = sql.raw(""); - -export const Prisma: Record = { - sql: sqlHelper, - raw: rawHelper, - join: joinHelper, - empty: emptyHelper, -}; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts deleted file mode 100644 index c8f83106..00000000 --- a/src/lib/prisma.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PrismaClient } from "@/generated/prisma/client"; -import { db } from "@/lib/db"; -import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade"; - -export const prisma = makePrisma(db) as unknown as PrismaClient; -export { PrismaHelper as Prisma }; diff --git a/src/lib/services/audit.test.ts b/src/lib/services/audit.test.ts index 97a12bff..06181433 100644 --- a/src/lib/services/audit.test.ts +++ b/src/lib/services/audit.test.ts @@ -1,15 +1,50 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const create = vi.hoisted(() => vi.fn()); -const findMany = vi.hoisted(() => vi.fn()); -const count = vi.hoisted(() => vi.fn()); -const userFindMany = vi.hoisted(() => vi.fn()); +const insertValues = vi.hoisted(() => vi.fn()); +const selectRows = vi.hoisted(() => vi.fn()); +const selectCount = vi.hoisted(() => vi.fn()); +const selectUsers = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/prisma", () => ({ - prisma: { - adminAuditLog: { create, findMany, count }, - user: { findMany: userFindMany }, +vi.mock("@/lib/db", () => ({ + db: { + insert: vi.fn(() => ({ values: insertValues })), + select: vi.fn((fields?: { value?: unknown; id?: unknown }) => { + // count() query passes { value: count() }; user lookup passes { id, username } + if (fields && "value" in fields && !("id" in fields)) { + return { + from: () => ({ + where: () => selectCount(), + }), + }; + } + if (fields && "id" in fields && "username" in fields) { + return { + from: () => ({ + where: () => selectUsers(), + }), + }; + } + // default: audit log rows + return { + from: () => ({ + where: () => ({ + orderBy: () => ({ + limit: () => ({ + offset: () => selectRows(), + }), + }), + }), + }), + }; + }), }, + AdminAuditLog: { + id: "id", + userId: "userId", + action: "action", + target: "target", + }, + User: { id: "id", username: "username" }, })); vi.mock("@/env", () => ({ env: {} })); @@ -22,14 +57,14 @@ beforeEach(() => { describe("logAudit", () => { it("creates an audit entry", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue({ id: 1 }); await logAudit({ userId: 1, action: "test_action", target: "user", targetId: 42, }); - const data = create.mock.calls[0][0].data; + const data = insertValues.mock.calls[0][0]; expect(data.userId).toBe(1); expect(data.action).toBe("test_action"); expect(data.target).toBe("user"); @@ -37,7 +72,7 @@ describe("logAudit", () => { }); it("redacts sensitive keys in payload", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue({ id: 1 }); await logAudit({ userId: 1, action: "update", @@ -46,32 +81,32 @@ describe("logAudit", () => { before: { username: "foo", password: "secret123" }, after: { username: "bar", password: "newsecret" }, }); - const data = create.mock.calls[0][0].data; + const data = insertValues.mock.calls[0][0]; expect(JSON.parse(data.before).password).toBe("[Redacted]"); expect(JSON.parse(data.after).password).toBe("[Redacted]"); expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" }); }); it("omits diff when only before or after is missing", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue({ id: 1 }); await logAudit({ userId: 1, action: "delete", target: "user", before: { username: "foo" }, }); - const data = create.mock.calls[0][0].data; + const data = insertValues.mock.calls[0][0]; expect(data.diff).toBeNull(); }); it("handles empty payloads", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue({ id: 1 }); await logAudit({ userId: 1, action: "view", target: "page" }); - expect(create).toHaveBeenCalledOnce(); + expect(insertValues).toHaveBeenCalledOnce(); }); it("flattens nested objects", async () => { - create.mockResolvedValue({ id: 1 }); + insertValues.mockResolvedValue({ id: 1 }); await logAudit({ userId: 1, action: "update_settings", @@ -79,14 +114,14 @@ describe("logAudit", () => { before: { nested: { key: "val" } }, after: {}, }); - const data = create.mock.calls[0][0].data; + const data = insertValues.mock.calls[0][0]; expect(JSON.parse(data.before)).toEqual({ nested: { key: "val" } }); }); }); describe("getAuditLogs", () => { it("returns paginated logs with usernames", async () => { - findMany.mockResolvedValue([ + selectRows.mockResolvedValue([ { id: 1, userId: 1, @@ -110,8 +145,8 @@ describe("getAuditLogs", () => { createdAt: "2024-01-02", }, ]); - count.mockResolvedValue(2); - userFindMany.mockResolvedValue([ + selectCount.mockResolvedValue([{ value: 2 }]); + selectUsers.mockResolvedValue([ { id: 1, username: "alice" }, { id: 2, username: "bob" }, ]); @@ -125,23 +160,15 @@ describe("getAuditLogs", () => { }); it("filters by search term", async () => { - findMany.mockResolvedValue([]); - count.mockResolvedValue(0); + selectRows.mockResolvedValue([]); + selectCount.mockResolvedValue([{ value: 0 }]); await getAuditLogs({ search: "test" }); - expect(findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - OR: [ - { action: { contains: "test" } }, - { target: { contains: "test" } }, - ], - }, - }), - ); + expect(selectRows).toHaveBeenCalled(); + expect(selectCount).toHaveBeenCalled(); }); it("falls back to User #id for unknown users", async () => { - findMany.mockResolvedValue([ + selectRows.mockResolvedValue([ { id: 1, userId: 99, @@ -154,8 +181,8 @@ describe("getAuditLogs", () => { createdAt: "2024-01-01", }, ]); - count.mockResolvedValue(1); - userFindMany.mockResolvedValue([]); + selectCount.mockResolvedValue([{ value: 1 }]); + selectUsers.mockResolvedValue([]); const result = await getAuditLogs(); expect(result.rows[0].username).toBe("User #99"); }); diff --git a/src/lib/services/audit.ts b/src/lib/services/audit.ts index 092c8d7f..363b6ee2 100644 --- a/src/lib/services/audit.ts +++ b/src/lib/services/audit.ts @@ -1,4 +1,5 @@ -import { prisma } from "../prisma"; +import { count, desc, inArray, like, or } from "drizzle-orm"; +import { AdminAuditLog, db, User } from "@/lib/db"; interface AuditEntry { userId: number; @@ -55,17 +56,15 @@ export async function logAudit(entry: AuditEntry): Promise { : undefined; const diff = computeDiff(sanitizedBefore, sanitizedAfter); - await prisma.adminAuditLog.create({ - data: { - userId: entry.userId, - action: entry.action, - target: entry.target, - targetId: entry.targetId, - before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null, - after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null, - diff: diff ? JSON.stringify(diff) : null, - createdAt: new Date().toISOString(), - }, + await db.insert(AdminAuditLog).values({ + userId: entry.userId, + action: entry.action, + target: entry.target, + targetId: entry.targetId, + before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null, + after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null, + diff: diff ? JSON.stringify(diff) : null, + createdAt: new Date().toISOString(), }); } @@ -80,29 +79,33 @@ export async function getAuditLogs(options: GetLogsOptions = {}) { const skip = (page - 1) * perPage; const where = search - ? { - OR: [ - { action: { contains: search } }, - { target: { contains: search } }, - ], - } - : {}; + ? or( + like(AdminAuditLog.action, `%${search}%`), + like(AdminAuditLog.target, `%${search}%`), + ) + : undefined; - const [rows, total] = await Promise.all([ - prisma.adminAuditLog.findMany({ - where, - orderBy: { id: "desc" }, - skip, - take: perPage, - }), - prisma.adminAuditLog.count({ where }), + const [rows, totalResult] = await Promise.all([ + db + .select() + .from(AdminAuditLog) + .where(where) + .orderBy(desc(AdminAuditLog.id)) + .limit(perPage) + .offset(skip), + db.select({ value: count() }).from(AdminAuditLog).where(where), ]); + const total = Number(totalResult[0]?.value ?? 0); + const userIds = [...new Set(rows.map((r) => r.userId))]; - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true }, - }); + const users = + userIds.length > 0 + ? await db + .select({ id: User.id, username: User.username }) + .from(User) + .where(inArray(User.id, userIds)) + : []; const userMap = new Map(users.map((u) => [u.id, u.username])); const enrichedRows = rows.map((r) => ({ diff --git a/src/lib/services/webhook.test.ts b/src/lib/services/webhook.test.ts index fa13d0bb..b55859e9 100644 --- a/src/lib/services/webhook.test.ts +++ b/src/lib/services/webhook.test.ts @@ -1,10 +1,19 @@ import { describe, expect, it, vi } from "vitest"; -const findUnique = vi.hoisted(() => vi.fn()); +const selectLimit = vi.hoisted(() => vi.fn().mockResolvedValue([])); const mockFetch = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true })); -vi.mock("@/lib/prisma", () => ({ - prisma: { websiteSetting: { findUnique } }, +vi.mock("@/lib/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: selectLimit, + })), + })), + })), + }, + WebsiteSetting: { key: "key", value: "value" }, })); vi.mock("@/env", () => ({ @@ -21,7 +30,6 @@ import { notify } from "./webhook"; describe("webhook", () => { it("sends a Discord notification when webhook URL is configured", async () => { - findUnique.mockResolvedValue(null); notify({ action: "ban", actor: "admin", @@ -41,7 +49,6 @@ describe("webhook", () => { it("does not throw when fetch fails", async () => { mockFetch.mockRejectedValueOnce(new Error("network error")); - findUnique.mockResolvedValue(null); expect(() => notify({ action: "ban", actor: "admin", target: "user1" }), ).not.toThrow(); diff --git a/src/lib/services/webhook.ts b/src/lib/services/webhook.ts index 85f776d8..b115522c 100644 --- a/src/lib/services/webhook.ts +++ b/src/lib/services/webhook.ts @@ -1,6 +1,7 @@ +import { eq } from "drizzle-orm"; import { env } from "@/env"; +import { db, WebsiteSetting } from "@/lib/db"; import { logger } from "@/lib/logger"; -import { prisma } from "../prisma"; export type { WebhookAction } from "@/types/admin"; @@ -40,9 +41,11 @@ async function getSetting( ): Promise { if (envValue) return envValue; try { - const setting = await prisma.websiteSetting.findUnique({ - where: { key: cmsKey }, - }); + const [setting] = await db + .select({ value: WebsiteSetting.value }) + .from(WebsiteSetting) + .where(eq(WebsiteSetting.key, cmsKey)) + .limit(1); return setting?.value || null; } catch { return null; diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts index a40f4e99..4d2a7ea4 100644 --- a/src/lib/staff-smoke-contract.test.ts +++ b/src/lib/staff-smoke-contract.test.ts @@ -120,7 +120,7 @@ describe("staff smoke contract", () => { expect(src).toContain("CameraWeb"); expect(src).toContain("tryRemoveLocalPhotoFile"); expect(src).toContain("@/lib/admin/photo-files"); - expect(src).not.toContain("@/lib/prisma"); + expect(src).toContain("@/lib/db"); }); it("guards dual ticket queues on admin and mod", () => {