diff --git a/src/server/features/projects/repositories/ProjectRepository.ts b/src/server/features/projects/repositories/ProjectRepository.ts index f8078c5..faed5c0 100644 --- a/src/server/features/projects/repositories/ProjectRepository.ts +++ b/src/server/features/projects/repositories/ProjectRepository.ts @@ -54,6 +54,21 @@ async function createProject( return id; } +async function tryCreateDefaultProject(organizationId: string) { + const id = crypto.randomUUID(); + const inserted = await db + .insert(projects) + .values({ + id, + organizationId, + name: "Default", + domain: null, + }) + .onConflictDoNothing() + .returning({ id: projects.id }); + return inserted.length > 0 ? id : null; +} + async function deleteProject(projectId: string, organizationId: string) { const project = await getProjectForOrganization(projectId, organizationId); if (!project) { @@ -76,5 +91,6 @@ export const ProjectRepository = { getProjectForOrganization, getProjectById, createProject, + tryCreateDefaultProject, deleteProject, } as const; diff --git a/src/server/features/projects/services/projects.test.ts b/src/server/features/projects/services/projects.test.ts index 2145261..75301b4 100644 --- a/src/server/features/projects/services/projects.test.ts +++ b/src/server/features/projects/services/projects.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ getProjectById: vi.fn(), getProjectForOrganization: vi.fn(), listProjects: vi.fn(), + tryCreateDefaultProject: vi.fn(), })); vi.mock("@/server/features/projects/repositories/ProjectRepository", () => ({ @@ -26,30 +27,24 @@ describe("project service", () => { for (const mock of Object.values(mocks)) mock.mockReset(); }); - it("recovers from the default project unique constraint race", async () => { + it("recovers from the default project creation race", async () => { mocks.getDefaultProjectForOrganization .mockResolvedValueOnce(null) .mockResolvedValueOnce(defaultProject); - mocks.createProject.mockRejectedValue( - new Error("UNIQUE constraint failed: projects.organization_id"), - ); + mocks.tryCreateDefaultProject.mockResolvedValue(null); const { getOrCreateDefaultProject } = await import("./projects"); await expect(getOrCreateDefaultProject("org_1")).resolves.toEqual( defaultProject, ); - expect(mocks.createProject).toHaveBeenCalledWith( - "org_1", - "Default", - undefined, - ); + expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1"); expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(2); }); it("does not swallow unrelated default project create failures", async () => { const error = new Error("D1 unavailable"); mocks.getDefaultProjectForOrganization.mockResolvedValue(null); - mocks.createProject.mockRejectedValue(error); + mocks.tryCreateDefaultProject.mockRejectedValue(error); const { getOrCreateDefaultProject } = await import("./projects"); await expect(getOrCreateDefaultProject("org_1")).rejects.toBe(error); diff --git a/src/server/features/projects/services/projects.ts b/src/server/features/projects/services/projects.ts index 027b8b1..8a293f0 100644 --- a/src/server/features/projects/services/projects.ts +++ b/src/server/features/projects/services/projects.ts @@ -51,41 +51,23 @@ export async function getOrCreateDefaultProject(organizationId: string) { return mapProject(existing); } - try { - const id = await ProjectRepository.createProject( - organizationId, - "Default", - undefined, - ); - + const id = await ProjectRepository.tryCreateDefaultProject(organizationId); + if (id) { return { id, name: "Default", domain: null, createdAt: new Date().toISOString(), }; - } catch (error) { - if (!isDefaultProjectUniqueConstraintError(error)) { - throw error; - } - - const createdProject = - await ProjectRepository.getDefaultProjectForOrganization(organizationId); - if (createdProject) { - return mapProject(createdProject); - } - - throw error; } -} -function isDefaultProjectUniqueConstraintError(error: unknown) { - if (!(error instanceof Error)) return false; - const message = error.message.toLowerCase(); - return ( - message.includes("unique constraint failed") && - message.includes("projects.organization_id") - ); + const createdProject = + await ProjectRepository.getDefaultProjectForOrganization(organizationId); + if (createdProject) { + return mapProject(createdProject); + } + + throw new AppError("INTERNAL_ERROR"); } export async function getProject(projectId: string) {