Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { PrismaPg } from "@prisma/adapter-pg";
import fs from "fs";
import path from "path";
import type { Prisma } from "../src/generated/prisma/client";
import { requireEnv } from "../src/lib/env";

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg({ connectionString: requireEnv("DATABASE_URL") });
const prisma = new PrismaClient({ adapter });

function unslugify(pathStr: string) {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function requireEnv(name: string): string {
const value = process.env[name];

if (value && value.trim() !== "") {
return value;
}

throw new Error(`Missing required environment variable: ${name}`);
}
3 changes: 2 additions & 1 deletion src/lib/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import "server-only";
import { PrismaClient } from "../generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { requireEnv } from "./env";

const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};

function createPrismaClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg({ connectionString: requireEnv("DATABASE_URL") });
return new PrismaClient({ adapter, errorFormat: "minimal" });
}

Expand Down
5 changes: 3 additions & 2 deletions src/lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { requireEnv } from "./env";

const globalForSupabase = globalThis as unknown as {
supabase?: ReturnType<typeof createClient>;
Expand All @@ -8,8 +9,8 @@ const globalForSupabase = globalThis as unknown as {
export const supabase =
globalForSupabase.supabase ??
createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
requireEnv("SUPABASE_URL"),
requireEnv("SUPABASE_SERVICE_ROLE_KEY")
);

if (process.env.NODE_ENV !== "production") {
Expand Down
106 changes: 105 additions & 1 deletion src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { validateName } from "./utils";
import { validateName, wrapAction } from "./utils";

describe("validateName", () => {
it.each<[string, string]>([
Expand All @@ -14,3 +14,107 @@ describe("validateName", () => {
expect(() => validateName(input)).toThrow("Name cannot be empty");
});
});

describe("wrapAction", () => {
it("maps Prisma unique constraint errors to user-friendly text", async () => {
const result = await wrapAction(async () => {
throw {
code: "P2002",
meta: { target: ["name"] },
};
});

expect(result).toEqual({
success: false,
error: "A record with this name already exists.",
});
});

it("maps Prisma not found errors to user-friendly text", async () => {
const result = await wrapAction(async () => {
throw { code: "P2025" };
});

expect(result).toEqual({
success: false,
error: "The requested record was not found.",
});
});

it("maps Prisma field too long errors", async () => {
const result = await wrapAction(async () => {
throw { code: "P2000" };
});

expect(result).toEqual({
success: false,
error: "A field contains a value that is too long.",
});
});

it("maps Prisma null constraint errors", async () => {
const result = await wrapAction(async () => {
throw { code: "P2011" };
});

expect(result).toEqual({
success: false,
error: "A required field is missing.",
});
});

it("maps Prisma relation violation on delete errors", async () => {
const result = await wrapAction(async () => {
throw { code: "P2014" };
});

expect(result).toEqual({
success: false,
error: "This record is referenced by other records and cannot be deleted.",
});
});

it("maps Prisma related record not found errors", async () => {
const result = await wrapAction(async () => {
throw { code: "P2015" };
});

expect(result).toEqual({
success: false,
error: "The related record could not be found.",
});
});

it("maps Prisma connection timeout errors", async () => {
const result = await wrapAction(async () => {
throw { code: "P2024" };
});

expect(result).toEqual({
success: false,
error: "Database connection timeout. Please try again.",
});
});

it("maps Prisma multiple errors", async () => {
const result = await wrapAction(async () => {
throw { code: "P2027" };
});

expect(result).toEqual({
success: false,
error: "Multiple database errors occurred. Please try again.",
});
});

it("falls back to generic error for unmapped Prisma codes", async () => {
const result = await wrapAction(async () => {
throw { code: "P9999" };
});

expect(result).toEqual({
success: false,
error: "A database operation failed. Please try again.",
});
});
});
67 changes: 66 additions & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,71 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { ActionResult } from "./types";

type PrismaLikeKnownRequestError = {
code: string;
meta?: {
target?: string | string[];
};
};

function isPrismaLikeKnownRequestError(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may use a normal instanceof like in the official docs:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error: unknown
): error is PrismaLikeKnownRequestError {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
typeof (error as { code: unknown }).code === "string" &&
(error as { code: string }).code.startsWith("P")
);
}

function formatPrismaErrorMessage(error: PrismaLikeKnownRequestError): string {
switch (error.code) {
case "P2000":
return "A field contains a value that is too long.";
case "P2002": {
const target = error.meta?.target;
const fields = Array.isArray(target)
? target.join(", ")
: typeof target === "string"
? target
: null;
return fields
? `A record with this ${fields} already exists.`
: "A record with these details already exists.";
}
case "P2003":
return "This action references related data that no longer exists.";
case "P2011":
return "A required field is missing.";
case "P2014":
return "This record is referenced by other records and cannot be deleted.";
case "P2015":
return "The related record could not be found.";
case "P2024":
return "Database connection timeout. Please try again.";
case "P2025":
return "The requested record was not found.";
case "P2027":
return "Multiple database errors occurred. Please try again.";
default:
return "A database operation failed. Please try again.";
}
Comment thread
shlok-p07 marked this conversation as resolved.
Outdated
}

function formatActionError(error: unknown): string {
if (isPrismaLikeKnownRequestError(error)) {
return formatPrismaErrorMessage(error);
}

if (error instanceof Error) {
return error.message;
}

return "Unknown error";
}

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Expand All @@ -22,7 +87,7 @@ export async function wrapAction<T>(
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: formatActionError(error),
};
}
}
Loading