diff --git a/.server-changes/prevent-duplicate-root-environments.md b/.server-changes/prevent-duplicate-root-environments.md new file mode 100644 index 0000000000..0710595deb --- /dev/null +++ b/.server-changes/prevent-duplicate-root-environments.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Prevent duplicate Staging and Preview environments when account setup requests overlap diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts index f3c215bfd4..cf6d1abce0 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts @@ -1,5 +1,6 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { + Prisma, type RuntimeEnvironment, type Organization, type Project, @@ -61,9 +62,16 @@ async function upsertEnvironment( type: RuntimeEnvironmentType, isBranchableEnvironment: boolean ) { - const existingEnvironment = project.environments.find((env) => env.type === type); + const existingEnvironment = project.environments.find( + (env) => env.type === type && env.parentEnvironmentId === null + ); - if (!existingEnvironment) { + if (existingEnvironment) { + await updateEnvConcurrencyLimits({ ...existingEnvironment, organization, project }); + return { status: "updated", environment: existingEnvironment }; + } + + try { const newEnvironment = await createEnvironment({ organization, project, @@ -72,8 +80,23 @@ async function upsertEnvironment( }); await updateEnvConcurrencyLimits({ ...newEnvironment, organization, project }); return { status: "created", environment: newEnvironment }; - } else { - await updateEnvConcurrencyLimits({ ...existingEnvironment, organization, project }); - return { status: "updated", environment: existingEnvironment }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + const existingAfterConflict = await prisma.runtimeEnvironment.findFirst({ + where: { + organizationId: organization.id, + projectId: project.id, + type, + parentEnvironmentId: null, + }, + }); + + if (existingAfterConflict) { + await updateEnvConcurrencyLimits({ ...existingAfterConflict, organization, project }); + return { status: "updated", environment: existingAfterConflict }; + } + } + + throw error; } } diff --git a/internal-packages/database/prisma/migrations/20260714140000_runtime_environment_unique_staging_preview_root/migration.sql b/internal-packages/database/prisma/migrations/20260714140000_runtime_environment_unique_staging_preview_root/migration.sql new file mode 100644 index 0000000000..153db8c2d0 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260714140000_runtime_environment_unique_staging_preview_root/migration.sql @@ -0,0 +1,4 @@ +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_projectId_type_staging_preview_root_key" +ON "RuntimeEnvironment" ("projectId", "type") +WHERE "parentEnvironmentId" IS NULL + AND "type" IN ('STAGING', 'PREVIEW'); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1b8642e866..2800dd5dd2 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -394,6 +394,8 @@ model RuntimeEnvironment { taskIdentifiers TaskIdentifier[] revokedApiKeys RevokedApiKey[] + // A partial unique index also enforces one STAGING/PREVIEW root per project and type. + // It is defined in SQL because Prisma does not support partial indexes in the schema. @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@index([parentEnvironmentId])