Make default project creation race-safe (#206)

This commit is contained in:
Ben Senescu 2026-05-20 17:34:57 -04:00 committed by GitHub
parent 2484f93ce6
commit 01ecf11415
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 30 additions and 37 deletions

View File

@ -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;

View File

@ -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);

View File

@ -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) {