chore(db): remove Prisma facade and drop prisma:generate from CI

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 14:38:42 +02:00
1 parent ca72966a37
commit 422567272c
17 files changed
+182 -1430

No files matched your search

+3 -3
View File
@@ -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")');
});
});
+4 -5
View File
@@ -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();
+2 -1
View File
@@ -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);
+26 -8
View File
@@ -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<string[]> => {
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);
-75
View File
@@ -1,75 +0,0 @@
import "server-only";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
const CACHE_TTL: Record<string, number> = {
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<T>(
model: string,
action: string,
args: unknown,
fn: () => Promise<T>,
): Promise<T> {
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<T>(
key: string,
ttl: number,
fn: () => Promise<T>,
): Promise<T> {
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;
}
@@ -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<string, string> };
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");
});
});
-50
View File
@@ -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<string, unknown> {
const map = new Map<string, unknown>();
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 });
});
});
File diff suppressed because it is too large. Load diff
-6
View File
@@ -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 };
+63 -36
View File
@@ -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");
});
+34 -31
View File
@@ -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<void> {
: 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) => ({
+12 -5
View File
@@ -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();
+7 -4
View File
@@ -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<string | null> {
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;
+1 -1
View File
@@ -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", () => {