Files
triggerdotdev--trigger.dev/apps/webapp/app/services/upsertBranch.server.ts
T
2026-07-13 13:32:57 +08:00

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,
};
}