Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dev-branch-default-sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

Centralize the `"default"` dev-branch sentinel behind a shared `DEFAULT_DEV_BRANCH` constant and `isDefaultDevBranch()` helper in `@trigger.dev/core/v3/utils/gitBranch`, replacing the hardcoded string literals duplicated across the CLI and server. No behavior change — `trigger dev` still targets the root development environment when no branch is specified.
2 changes: 1 addition & 1 deletion apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export async function acceptInvite({
organization: invite.organization,
project,
type: "DEVELOPMENT",
isBranchableEnvironment: false,
isBranchableEnvironment: true,
member,
prismaClient: tx,
});
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/models/project.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export async function createProject(
organization,
project,
type: "DEVELOPMENT",
isBranchableEnvironment: false,
isBranchableEnvironment: true,
member,
});
}
Expand Down
31 changes: 25 additions & 6 deletions apps/webapp/app/models/runtimeEnvironment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { logger } from "~/services/logger.server";
import { getUsername } from "~/utils/username";
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";

export type { RuntimeEnvironment };

Expand Down Expand Up @@ -100,11 +100,11 @@ export async function findEnvironmentByApiKey(
...authIncludeBase,
childEnvironments: branchName
? {
where: {
branchName: sanitizeBranchName(branchName),
archivedAt: null,
},
}
where: {
branchName: sanitizeBranchName(branchName),
archivedAt: null,
},
}
: undefined,
} satisfies Prisma.RuntimeEnvironmentInclude;

Expand Down Expand Up @@ -163,6 +163,25 @@ export async function findEnvironmentByApiKey(
return null;
}

// If there is a named DEV branch (other than default), return it
if (environment.type === "DEVELOPMENT" && branchName !== undefined && !isDefaultDevBranch(branchName)) {
const childEnvironment = environment.childEnvironments.at(0);

if (childEnvironment) {
return toAuthenticated({
...childEnvironment,
apiKey: environment.apiKey,
orgMember: environment.orgMember,
organization: environment.organization,
project: environment.project,
});
}

//A branch was specified but no child environment was found
return null;

}

return toAuthenticated(environment);
}

Expand Down
22 changes: 14 additions & 8 deletions apps/webapp/app/presenters/OrganizationsPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { env } from "~/env.server";
import { flags } from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
import { devPresence } from "./v3/DevPresence.server";
import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server";
Comment on lines +16 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the duplicate getRecentBranchIds read.

Line 107 performs a Redis read that is never used, and Line 112 immediately calls hydrateEnvsWithActivity() which already performs the same read internally. This adds unnecessary network I/O on every request.

💡 Suggested fix
-import { devPresence } from "./v3/DevPresence.server";
 import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server";
@@
-    const recentDevBranchIds = await devPresence.getRecentBranchIds(user.id, fullProject.id);
-
     const environments = fullProject.
       environments.filter((env) => env.type !== "DEVELOPMENT" || env.orgMember?.userId === user.id);

Also applies to: 107-113


export class OrganizationsPresenter {
#prismaClient: PrismaClient;
Expand Down Expand Up @@ -102,6 +104,13 @@ export class OrganizationsPresenter {
throw redirect(newProjectPath(organization));
}

const recentDevBranchIds = await devPresence.getRecentBranchIds(user.id, fullProject.id);

const environments = fullProject.
environments.filter((env) => env.type !== "DEVELOPMENT" || env.orgMember?.userId === user.id);

const environmentsWithActivity = await hydrateEnvsWithActivity(user.id, fullProject.id, environments);

const environment = this.#getEnvironment({
user,
projectId: fullProject.id,
Expand All @@ -115,13 +124,7 @@ export class OrganizationsPresenter {
project: {
...fullProject,
createdAt: fullProject.createdAt,
environments: sortEnvironments(
fullProject.environments.filter((env) => {
if (env.type !== "DEVELOPMENT") return true;
if (env.orgMember?.userId === user.id) return true;
return false;
})
),
environments: sortEnvironments(environmentsWithActivity),
},
environment,
};
Expand Down Expand Up @@ -244,7 +247,10 @@ export class OrganizationsPresenter {

//otherwise show their dev environment
const yourDevEnvironment = environments.find(
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
(env) =>
env.type === "DEVELOPMENT" &&
env.parentEnvironmentId === null &&
env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
type RuntimeEnvironment,
type PrismaClient,
RuntimeEnvironmentType,
type RuntimeEnvironmentType,
} from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
Expand Down Expand Up @@ -140,7 +140,7 @@ export class SelectBestEnvironmentPresenter {
}

async selectBestEnvironment<
T extends { id: string; type: RuntimeEnvironmentType; orgMember: { userId: string } | null }
T extends { id: string; type: RuntimeEnvironmentType; slug: string; orgMember: { userId: string } | null }
>(projectId: string, user: UserFromSession, environments: T[]): Promise<T> {
//try get current environment from prefs
const currentEnvironmentId: string | undefined =
Expand All @@ -153,7 +153,8 @@ export class SelectBestEnvironmentPresenter {

//otherwise show their dev environment
const yourDevEnvironment = environments.find(
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
// Return the default dev environment, not a branch
(env) => env.type === "DEVELOPMENT" && env.slug === "dev" && env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
Expand Down
102 changes: 65 additions & 37 deletions apps/webapp/app/presenters/v3/BranchesPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { GitMeta } from "@trigger.dev/core/v3";
import { GitMeta, } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import { type z } from "zod";
import { type Prisma, type PrismaClient, prisma } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { checkBranchLimit } from "~/services/upsertBranch.server";
import { devPresence } from "./DevPresence.server";
import { sortEnvironments } from "~/utils/environmentSort";

type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
export type Branch = Result["branches"][number];
Expand Down Expand Up @@ -58,12 +62,14 @@ export class BranchesPresenter {
public async call({
userId,
projectSlug,
env,
showArchived = false,
search,
page = 1,
}: {
userId: User["id"];
projectSlug: Project["slug"];
env: "preview" | "development";
} & Options) {
const project = await this.#prismaClient.project.findFirst({
select: {
Expand All @@ -86,12 +92,16 @@ export class BranchesPresenter {
throw new Error("Project not found");
}

// TODO audit mishmash of preview/developement preview/dev stg/dev PREVIEW/DEVELOPMENT
const envType = env === "preview" ? "PREVIEW" : "DEVELOPMENT";

const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: project.id,
type: envType,
isBranchableEnvironment: true,
},
});
Expand Down Expand Up @@ -119,23 +129,30 @@ export class BranchesPresenter {
};
}

// The default DEV branch has no branchName (it's the root dev env, stored
// with branchName: null), so searching for it by name wouldn't display it.
// Hacky way around that: always include the null-branchName root env.
const branchNameWhere = envType === "DEVELOPMENT" ?
search
? { OR: [{ contains: search, mode: "insensitive" as const }, { is: null }] }
: {} :
search
? { contains: search, mode: "insensitive" as const }
: { not: null };
Comment on lines +132 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/webapp/app/presenters/v3/BranchesPresenter.server.ts | sed -n '130,190p'

Repository: triggerdotdev/trigger.dev

Length of output: 2676


🏁 Script executed:

find . -name "*.prisma" -type f | head -20

Repository: triggerdotdev/trigger.dev

Length of output: 119


🏁 Script executed:

grep -A 50 "model RuntimeEnvironment" ./internal-packages/database/prisma/schema.prisma | head -80

Repository: triggerdotdev/trigger.dev

Length of output: 2821


🏁 Script executed:

head -50 apps/webapp/app/presenters/v3/BranchesPresenter.server.ts

Repository: triggerdotdev/trigger.dev

Length of output: 1999


🏁 Script executed:

rg "Prisma.*WhereInput" apps/webapp/app/presenters/v3/BranchesPresenter.server.ts -A 3 -B 3

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

cat -n apps/webapp/app/presenters/v3/BranchesPresenter.server.ts | sed -n '1,30p'

Repository: triggerdotdev/trigger.dev

Length of output: 1585


🏁 Script executed:

web_search
Prisma scalar nullable field OR filter syntax StringNullableFilter

Repository: triggerdotdev/trigger.dev

Length of output: 165


🌐 Web query:

Prisma StringNullableFilter OR operator scalar field syntax

💡 Result:

In Prisma, there is no specialized "StringNullableFilter OR" operator. Instead, you use the standard, composable OR operator at the level of your where clause, allowing you to combine any valid filter conditions, including those for nullable scalar fields [1][2][3]. To filter on a nullable string field within an OR condition, you simply place your conditions inside an array assigned to the OR key [1][4]. Example Syntax: const users = await prisma.user.findMany({ where: { OR: [ { name: { equals: "Alice" } }, { bio: { equals: null } }, // Targeting a StringNullableFilter { bio: { contains: "developer" } } ], }, }); Key Considerations: 1. Composition: The OR operator accepts an array of filter objects [1][3]. Each object in the array represents a condition, and at least one must be true for the record to be returned [3]. 2. Nullable Fields: When a field is nullable (StringNullableFilter), you can explicitly filter for null or use string operators like contains, startsWith, or endsWith, which are available on the StringNullableFilter type [5]. 3. Handling Undefined: Be cautious when building dynamic queries. If you pass undefined to a filter, Prisma ignores that specific filter [6]. If all elements of an OR array are omitted due to being undefined, it can lead to unexpected query behavior, such as generating an empty result or a 1=0 SQL clause [7][6]. 4. Nesting: The OR, AND, and NOT operators are fully nestable, allowing you to create complex boolean logic as needed [2][3]. For nullable fields, you can check for null values by setting the field to null directly or using the { equals: null } syntax [8].

Citations:


🏁 Script executed:

cat -n apps/webapp/app/presenters/v3/BranchesPresenter.server.ts | sed -n '130,195p'

Repository: triggerdotdev/trigger.dev

Length of output: 2892


🏁 Script executed:

cat -n apps/webapp/app/presenters/v3/BranchesPresenter.server.ts | sed -n '195,210p'

Repository: triggerdotdev/trigger.dev

Length of output: 619


🏁 Script executed:

cat > /tmp/test_prisma_types.ts << 'EOF'
// Testing Prisma filter types
import { type Prisma } from "`@prisma/client`";

// What the current code is trying to do:
const invalidWhere: Prisma.RuntimeEnvironmentWhereInput = {
  branchName: {
    OR: [{ contains: "test", mode: "insensitive" as const }, { is: null }]
  }
};

// What the correct approach should be:
const correctWhere: Prisma.RuntimeEnvironmentWhereInput = {
  OR: [
    { branchName: { contains: "test", mode: "insensitive" } },
    { branchName: null }
  ]
};

// Or using spread approach from the suggested fix:
const correctWhereSpread: Prisma.RuntimeEnvironmentWhereInput = {
  branchName: { contains: "test", mode: "insensitive" }
};

export { invalidWhere, correctWhere, correctWhereSpread };
EOF
cat /tmp/test_prisma_types.ts

Repository: triggerdotdev/trigger.dev

Length of output: 789


Fix Prisma filter composition for branchName scalar field.

Lines 135-141 build an invalid filter structure. The OR operator belongs at the RuntimeEnvironmentWhereInput level, not nested under the branchName scalar field. When this filter is passed as branchName: branchNameWhere at lines 149 and 182, it violates Prisma's StringNullableFilter type contract, which does not accept an OR property.

💡 Suggested fix
-    const branchNameWhere = envType === "DEVELOPMENT" ?
-      search
-        ? { OR: [{ contains: search, mode: "insensitive" as const }, { is: null }] }
-        : {} :
-      search
-        ? { contains: search, mode: "insensitive" as const }
-        : { not: null };
+    const whereForBranchSearch: Prisma.RuntimeEnvironmentWhereInput =
+      envType === "DEVELOPMENT"
+        ? search
+          ? {
+              OR: [
+                { branchName: { contains: search, mode: "insensitive" } },
+                { branchName: null },
+              ],
+            }
+          : {}
+        : search
+          ? { branchName: { contains: search, mode: "insensitive" } }
+          : { branchName: { not: null } };
@@
-        branchName: branchNameWhere,
+        ...whereForBranchSearch,
@@
-        branchName: branchNameWhere,
+        ...whereForBranchSearch,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// The default DEV branch has no branchName (it's the root dev env, stored
// with branchName: null), so searching for it by name wouldn't display it.
// Hacky way around that: always include the null-branchName root env.
const branchNameWhere = envType === "DEVELOPMENT" ?
search
? { OR: [{ contains: search, mode: "insensitive" as const }, { is: null }] }
: {} :
search
? { contains: search, mode: "insensitive" as const }
: { not: null };
const whereForBranchSearch: Prisma.RuntimeEnvironmentWhereInput =
envType === "DEVELOPMENT"
? search
? {
OR: [
{ branchName: { contains: search, mode: "insensitive" } },
{ branchName: null },
],
}
: {}
: search
? { branchName: { contains: search, mode: "insensitive" } }
: { branchName: { not: null } };

const orgMemberWhere = envType === "DEVELOPMENT" ? { orgMember: { userId } } : {};


const visibleCount = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
branchName: search
? {
contains: search,
mode: "insensitive",
}
: {
not: null,
},
type: envType,
branchName: branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
});

// Limits
const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id);
const limits = await checkBranchLimit({ prisma: this.#prismaClient, organizationId: project.organizationId, projectId: project.id, userId, env });

const [currentPlan, plans] = await Promise.all([
getCurrentPlan(project.organizationId),
Expand All @@ -161,14 +178,9 @@ export class BranchesPresenter {
},
where: {
projectId: project.id,
branchName: search
? {
contains: search,
mode: "insensitive",
}
: {
not: null,
},
type: envType,
branchName: branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
orderBy: {
Expand All @@ -178,35 +190,34 @@ export class BranchesPresenter {
take: BRANCHES_PER_PAGE,
});

const totalBranchesWhere = envType === "DEVELOPMENT" ? {} : { not: null };
const totalBranches = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
branchName: {
not: null,
},
type: envType,
branchName: totalBranchesWhere,
...orgMemberWhere,
},
});


const branchesFiltered = branches
.filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null)
.map((branch) => ({
...branch,
git: processGitMetadata(branch.git),
branchName: branch.branchName ?? DEFAULT_DEV_BRANCH,
}));

const branchesWithActivity = await hydrateEnvsWithActivity(userId, project.id, branchesFiltered);
const branchesSorted = sortEnvironments(branchesWithActivity);

return {
branchableEnvironment,
currentPage: page,
totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE),
hasBranches: totalBranches > 0,
branches: branches.flatMap((branch) => {
if (branch.branchName === null) {
return [];
}

const git = processGitMetadata(branch.git);

return [
{
...branch,
branchName: branch.branchName,
git,
} as const,
];
}),
branches: branchesSorted,
hasFilters,
limits,
canPurchaseBranches,
Expand All @@ -218,6 +229,23 @@ export class BranchesPresenter {
}
}

export async function hydrateEnvsWithActivity<T extends { type: RuntimeEnvironmentType; id: string }>
(userId: string, projectId: string, environments: T[]): Promise<Array<T & { lastActivity: Date | undefined; isConnected: boolean | undefined }>> {
const recentDevBranchIds = await devPresence.getRecentBranchIds(userId, projectId);

return Promise.all(environments.map(async (env) => {
if (env.type !== "DEVELOPMENT") {
return { ...env, lastActivity: undefined, isConnected: undefined };
}

const devHit = recentDevBranchIds.get(env.id);
const lastActivity = devHit === undefined ? undefined : devHit;
// TODO change dev-presence to a different data structure to avoid N calls?
const isConnected = devHit === undefined ? undefined : await devPresence.isConnected(env.id);
return { ...env, lastActivity, isConnected };
}));
}

export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null {
if (!data) return null;

Expand Down
Loading
Loading