263 lines
8.8 KiB
TypeScript
263 lines
8.8 KiB
TypeScript
import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database";
|
|
import slug from "slug";
|
|
import { prisma } from "~/db.server";
|
|
import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server";
|
|
import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
|
|
import {
|
|
type BranchableEnvironmentType,
|
|
isBranchableEnvironment,
|
|
rootEnvironmentWhere,
|
|
toBranchableEnvironmentType,
|
|
} from "~/utils/branchableEnvironment";
|
|
import { logger } from "./logger.server";
|
|
import { getCurrentPlan, getLimit } from "./platform.v3.server";
|
|
import { type z } from "zod";
|
|
import invariant from "tiny-invariant";
|
|
import { type CreateBranchOptions } from "~/utils/branches";
|
|
import {
|
|
applyBillingLimitPauseAfterEnvCreate,
|
|
getInitialEnvPauseStateForBillingLimit,
|
|
} from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server";
|
|
|
|
type CreateBranchOptions = z.infer<typeof CreateBranchOptions>;
|
|
|
|
export class UpsertBranchService {
|
|
#prismaClient: PrismaClient;
|
|
|
|
constructor(prismaClient: PrismaClient = prisma) {
|
|
this.#prismaClient = prismaClient;
|
|
}
|
|
|
|
public async call(
|
|
// The orgFilter approach is not ideal but we need to keep it this way for now because of how the service is used in routes and api endpoints.
|
|
// Currently authorization checks are spread across the controller/route layer and the service layer. Often we check in multiple places for org/project membership.
|
|
// Ideally we would take care of both the authentication and authorization checks in the controllers and routes.
|
|
// That would unify how we handle authorization and org/project membership checks. Also it would make the service layer queries simpler.
|
|
orgFilter:
|
|
| { type: "userMembership"; userId: string }
|
|
| { type: "orgId"; organizationId: string },
|
|
{ projectId, env, branchName, git }: CreateBranchOptions
|
|
) {
|
|
const parentEnvType = toBranchableEnvironmentType(env);
|
|
// Dev branch creation is always user-scoped (org tokens are rejected upstream),
|
|
// so we can disambiguate the per-member dev root by userId.
|
|
const userId = orgFilter.type === "userMembership" ? orgFilter.userId : undefined;
|
|
|
|
const sanitizedBranchName = sanitizeBranchName(branchName);
|
|
if (!sanitizedBranchName) {
|
|
return {
|
|
success: false as const,
|
|
error: "Branch name has an invalid format",
|
|
};
|
|
}
|
|
|
|
if (!isValidGitBranchName(sanitizedBranchName)) {
|
|
return {
|
|
success: false as const,
|
|
error: "Invalid branch name, contains disallowed character sequences",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId,
|
|
// Locate the branchable parent structurally (root env of this type),
|
|
// not by its magic slug. Branchability is asserted below.
|
|
...rootEnvironmentWhere(parentEnvType, { userId }),
|
|
organization:
|
|
orgFilter.type === "userMembership"
|
|
? {
|
|
members: {
|
|
some: {
|
|
userId: orgFilter.userId,
|
|
},
|
|
},
|
|
}
|
|
: { id: orgFilter.organizationId },
|
|
},
|
|
include: {
|
|
organization: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
maximumConcurrencyLimit: true,
|
|
},
|
|
},
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Dev environments are scoped per org member, so a dev branch must inherit
|
|
// its parent's orgMemberId. Preview parents have no orgMember (orgMemberId is null).
|
|
if (!parentEnvironment) {
|
|
// This should never happen
|
|
if (env === "development") {
|
|
return {
|
|
success: false as const,
|
|
error: "Error: No default dev runtime environment setup.",
|
|
};
|
|
}
|
|
return {
|
|
success: false as const,
|
|
error: "You don't have preview branches setup. Go to the dashboard to enable them.",
|
|
};
|
|
}
|
|
|
|
if (!isBranchableEnvironment(parentEnvironment)) {
|
|
return {
|
|
success: false as const,
|
|
error: `Your ${env} environment is not branchable`,
|
|
};
|
|
}
|
|
|
|
const limits = await checkBranchLimit({
|
|
prisma: this.#prismaClient,
|
|
organizationId: parentEnvironment.organization.id,
|
|
projectId: parentEnvironment.project.id,
|
|
type: parentEnvType,
|
|
userId,
|
|
newBranchName: sanitizedBranchName,
|
|
});
|
|
|
|
if (limits.isAtLimit) {
|
|
// DEVELOPMENT has no upgrade path, so only PREVIEW mentions upgrading.
|
|
const remediation =
|
|
parentEnvType === "PREVIEW"
|
|
? "Use the CLI to view your existing branches and archive any you no longer need, or upgrade to get more."
|
|
: "Use the CLI to view your existing branches and archive any you no longer need.";
|
|
|
|
return {
|
|
success: false as const,
|
|
error: `You've used all ${limits.used} of ${limits.limit} branches for your plan. ${remediation}`,
|
|
};
|
|
}
|
|
|
|
const branchSlug = `${slug(`${parentEnvironment.slug}-${sanitizedBranchName}`)}`;
|
|
const apiKey = createApiKeyForEnv(parentEnvironment.type);
|
|
const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type);
|
|
const shortcode = branchSlug;
|
|
const billingPause = await getInitialEnvPauseStateForBillingLimit(
|
|
parentEnvironment.organization.id,
|
|
parentEnvironment.type
|
|
);
|
|
|
|
const now = new Date();
|
|
const branch = await this.#prismaClient.runtimeEnvironment.upsert({
|
|
where: {
|
|
projectId_shortcode: {
|
|
projectId: parentEnvironment.project.id,
|
|
shortcode: shortcode,
|
|
},
|
|
},
|
|
create: {
|
|
slug: branchSlug,
|
|
apiKey,
|
|
pkApiKey,
|
|
shortcode,
|
|
maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit,
|
|
paused: billingPause.paused,
|
|
pauseSource: billingPause.pauseSource,
|
|
organization: {
|
|
connect: {
|
|
id: parentEnvironment.organization.id,
|
|
},
|
|
},
|
|
project: {
|
|
connect: { id: parentEnvironment.project.id },
|
|
},
|
|
branchName: sanitizedBranchName,
|
|
type: parentEnvironment.type,
|
|
parentEnvironment: {
|
|
connect: { id: parentEnvironment.id },
|
|
},
|
|
orgMember: parentEnvironment.orgMemberId
|
|
? { connect: { id: parentEnvironment.orgMemberId } }
|
|
: undefined,
|
|
git: git ?? undefined,
|
|
},
|
|
update: {
|
|
git: git ?? undefined,
|
|
},
|
|
include: {
|
|
organization: true,
|
|
project: true,
|
|
},
|
|
});
|
|
|
|
const alreadyExisted = branch.createdAt < now;
|
|
await applyBillingLimitPauseAfterEnvCreate(branch);
|
|
|
|
return {
|
|
success: true as const,
|
|
alreadyExisted: alreadyExisted,
|
|
branch,
|
|
organization: parentEnvironment.organization,
|
|
project: parentEnvironment.project,
|
|
};
|
|
} catch (e) {
|
|
logger.error("CreateBranchService error", { error: e });
|
|
return {
|
|
success: false as const,
|
|
error: e instanceof Error ? e.message : "Failed to create branch",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function checkBranchLimit({
|
|
prisma,
|
|
organizationId,
|
|
projectId,
|
|
userId,
|
|
type,
|
|
newBranchName,
|
|
}: {
|
|
prisma: PrismaClientOrTransaction;
|
|
organizationId: string;
|
|
projectId: string;
|
|
userId?: string;
|
|
type: BranchableEnvironmentType;
|
|
newBranchName?: string;
|
|
}) {
|
|
let orgMemberWhere = {};
|
|
if (type === "DEVELOPMENT") {
|
|
invariant(userId, "Cannot use org access for dev server");
|
|
orgMemberWhere = { orgMember: { userId } };
|
|
}
|
|
|
|
const usedEnvs = await prisma.runtimeEnvironment.findMany({
|
|
where: {
|
|
projectId,
|
|
type,
|
|
// For PREVIEW, count only branches (exclude the branchable parent). For
|
|
// DEVELOPMENT, the root env counts toward the limit alongside its branches.
|
|
...(type === "PREVIEW" ? { parentEnvironmentId: { not: null } } : {}),
|
|
...orgMemberWhere,
|
|
archivedAt: null,
|
|
},
|
|
});
|
|
|
|
const count = newBranchName
|
|
? usedEnvs.filter((env) => env.branchName !== newBranchName).length
|
|
: usedEnvs.length;
|
|
|
|
const limitName = type === "PREVIEW" ? "branches" : "branchesDev";
|
|
const baseLimit = await getLimit(organizationId, limitName, 100_000_000);
|
|
const currentPlan = await getCurrentPlan(organizationId);
|
|
const purchasedBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0;
|
|
// We deliberately include purchased PREVIEW branches in DEV limits... (not documented anywhere)
|
|
const limit = baseLimit + purchasedBranches;
|
|
|
|
return {
|
|
used: count,
|
|
limit,
|
|
isAtLimit: count >= limit,
|
|
};
|
|
}
|