Skip to content

Commit c0ef8e8

Browse files
committed
feat(webapp): make development environments branchable (API + auth)
Extend branch support to DEVELOPMENT environments alongside PREVIEW. - UpsertBranchRequestBody / branches API accept env "development" as well as "preview"; the upsert service resolves the parent env by slug ("preview" or "dev") and scopes dev branches per org member. - checkBranchLimit applies a separate "branchesDev" limit and filters dev branches by the owning org member. - API-key and JWT auth resolve branch child environments for both PREVIEW and DEVELOPMENT parents; findEnvironmentByApiKey returns the dev branch child when a non-default branch is requested. - archiveBranch refuses to archive the default dev branch and reports the branch type so callers can route appropriately. - Presenters and presence are env/branch aware. Backwards compatible with the existing CLI: requests that send env "preview" (or no dev branch) behave exactly as before. TRI-8726
1 parent 62655c5 commit c0ef8e8

15 files changed

Lines changed: 284 additions & 136 deletions

apps/webapp/app/models/member.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export async function acceptInvite({
215215
organization: invite.organization,
216216
project,
217217
type: "DEVELOPMENT",
218-
isBranchableEnvironment: false,
218+
isBranchableEnvironment: true,
219219
member,
220220
prismaClient: tx,
221221
});

apps/webapp/app/models/project.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export async function createProject(
126126
organization,
127127
project,
128128
type: "DEVELOPMENT",
129-
isBranchableEnvironment: false,
129+
isBranchableEnvironment: true,
130130
member,
131131
});
132132
}

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,11 @@ export async function findEnvironmentByApiKey(
9999
...authIncludeBase,
100100
childEnvironments: branchName
101101
? {
102-
where: {
103-
branchName: sanitizeBranchName(branchName),
104-
archivedAt: null,
105-
},
106-
}
102+
where: {
103+
branchName: sanitizeBranchName(branchName),
104+
archivedAt: null,
105+
},
106+
}
107107
: undefined,
108108
} satisfies Prisma.RuntimeEnvironmentInclude;
109109

@@ -162,6 +162,25 @@ export async function findEnvironmentByApiKey(
162162
return null;
163163
}
164164

165+
// If there is no branch name (or "default"), then fall back to regular behavior, return root env
166+
if (environment.type === "DEVELOPMENT" && branchName !== undefined && branchName !== "default") {
167+
const childEnvironment = environment.childEnvironments.at(0);
168+
169+
if (childEnvironment) {
170+
return toAuthenticated({
171+
...childEnvironment,
172+
apiKey: environment.apiKey,
173+
orgMember: environment.orgMember,
174+
organization: environment.organization,
175+
project: environment.project,
176+
});
177+
}
178+
179+
//A branch was specified but no child environment was found
180+
return null;
181+
182+
}
183+
165184
return toAuthenticated(environment);
166185
}
167186

apps/webapp/app/presenters/OrganizationsPresenter.server.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
1313
import { env } from "~/env.server";
1414
import { flags } from "~/v3/featureFlags.server";
1515
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
16+
import { devPresence } from "./v3/DevPresence.server";
17+
import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server";
1618

1719
export class OrganizationsPresenter {
1820
#prismaClient: PrismaClient;
@@ -102,6 +104,13 @@ export class OrganizationsPresenter {
102104
throw redirect(newProjectPath(organization));
103105
}
104106

107+
const recentDevBranchIds = await devPresence.getRecentBranchIds(user.id, fullProject.id);
108+
109+
const environments = fullProject.
110+
environments.filter((env) => env.type !== "DEVELOPMENT" || env.orgMember?.userId === user.id);
111+
112+
const environmentsWithActivity = await hydrateEnvsWithActivity(user.id, fullProject.id, environments);
113+
105114
const environment = this.#getEnvironment({
106115
user,
107116
projectId: fullProject.id,
@@ -115,13 +124,7 @@ export class OrganizationsPresenter {
115124
project: {
116125
...fullProject,
117126
createdAt: fullProject.createdAt,
118-
environments: sortEnvironments(
119-
fullProject.environments.filter((env) => {
120-
if (env.type !== "DEVELOPMENT") return true;
121-
if (env.orgMember?.userId === user.id) return true;
122-
return false;
123-
})
124-
),
127+
environments: sortEnvironments(environmentsWithActivity),
125128
},
126129
environment,
127130
};
@@ -244,7 +247,7 @@ export class OrganizationsPresenter {
244247

245248
//otherwise show their dev environment
246249
const yourDevEnvironment = environments.find(
247-
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
250+
(env) => env.type === "DEVELOPMENT" && env.branchName === null && env.orgMember?.userId === user.id
248251
);
249252
if (yourDevEnvironment) {
250253
return yourDevEnvironment;

apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
type RuntimeEnvironment,
33
type PrismaClient,
4-
RuntimeEnvironmentType,
4+
type RuntimeEnvironmentType,
55
} from "@trigger.dev/database";
66
import { prisma } from "~/db.server";
77
import { logger } from "~/services/logger.server";
@@ -140,7 +140,7 @@ export class SelectBestEnvironmentPresenter {
140140
}
141141

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

154154
//otherwise show their dev environment
155155
const yourDevEnvironment = environments.find(
156-
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
156+
// Return the default dev environment, not a branch
157+
(env) => env.type === "DEVELOPMENT" && env.slug === "dev" && env.orgMember?.userId === user.id
157158
);
158159
if (yourDevEnvironment) {
159160
return yourDevEnvironment;

apps/webapp/app/presenters/v3/BranchesPresenter.server.ts

Lines changed: 63 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { GitMeta } from "@trigger.dev/core/v3";
1+
import { GitMeta, } from "@trigger.dev/core/v3";
2+
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
23
import { type z } from "zod";
34
import { type Prisma, type PrismaClient, prisma } from "~/db.server";
45
import { type Project } from "~/models/project.server";
56
import { type User } from "~/models/user.server";
67
import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
78
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
89
import { checkBranchLimit } from "~/services/upsertBranch.server";
10+
import { devPresence } from "./DevPresence.server";
11+
import { sortEnvironments } from "~/utils/environmentSort";
912

1013
type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
1114
export type Branch = Result["branches"][number];
@@ -58,12 +61,14 @@ export class BranchesPresenter {
5861
public async call({
5962
userId,
6063
projectSlug,
64+
env,
6165
showArchived = false,
6266
search,
6367
page = 1,
6468
}: {
6569
userId: User["id"];
6670
projectSlug: Project["slug"];
71+
env: "preview" | "development";
6772
} & Options) {
6873
const project = await this.#prismaClient.project.findFirst({
6974
select: {
@@ -86,12 +91,16 @@ export class BranchesPresenter {
8691
throw new Error("Project not found");
8792
}
8893

94+
// TODO audit mishmash of preview/developement preview/dev stg/dev PREVIEW/DEVELOPMENT
95+
const envType = env === "preview" ? "PREVIEW" : "DEVELOPMENT";
96+
8997
const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
9098
select: {
9199
id: true,
92100
},
93101
where: {
94102
projectId: project.id,
103+
type: envType,
95104
isBranchableEnvironment: true,
96105
},
97106
});
@@ -119,23 +128,29 @@ export class BranchesPresenter {
119128
};
120129
}
121130

131+
// The "default" DEV branch has no branchName, so searching 'default' wouldn't
132+
// display it... hacky way around that just always show it.
133+
const branchNameWhere = envType === "DEVELOPMENT" ?
134+
search
135+
? { OR: [{ contains: search, mode: "insensitive" as const }, { is: null }] }
136+
: {} :
137+
search
138+
? { contains: search, mode: "insensitive" as const }
139+
: { not: null };
140+
const orgMemberWhere = envType === "DEVELOPMENT" ? { orgMember: { userId } } : {};
141+
142+
122143
const visibleCount = await this.#prismaClient.runtimeEnvironment.count({
123144
where: {
124145
projectId: project.id,
125-
branchName: search
126-
? {
127-
contains: search,
128-
mode: "insensitive",
129-
}
130-
: {
131-
not: null,
132-
},
146+
type: envType,
147+
branchName: branchNameWhere,
148+
...orgMemberWhere,
133149
...(showArchived ? {} : { archivedAt: null }),
134150
},
135151
});
136152

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

140155
const [currentPlan, plans] = await Promise.all([
141156
getCurrentPlan(project.organizationId),
@@ -161,14 +176,9 @@ export class BranchesPresenter {
161176
},
162177
where: {
163178
projectId: project.id,
164-
branchName: search
165-
? {
166-
contains: search,
167-
mode: "insensitive",
168-
}
169-
: {
170-
not: null,
171-
},
179+
type: envType,
180+
branchName: branchNameWhere,
181+
...orgMemberWhere,
172182
...(showArchived ? {} : { archivedAt: null }),
173183
},
174184
orderBy: {
@@ -178,35 +188,34 @@ export class BranchesPresenter {
178188
take: BRANCHES_PER_PAGE,
179189
});
180190

191+
const totalBranchesWhere = envType === "DEVELOPMENT" ? {} : { not: null };
181192
const totalBranches = await this.#prismaClient.runtimeEnvironment.count({
182193
where: {
183194
projectId: project.id,
184-
branchName: {
185-
not: null,
186-
},
195+
type: envType,
196+
branchName: totalBranchesWhere,
197+
...orgMemberWhere,
187198
},
188199
});
189200

201+
202+
const branchesFiltered = branches
203+
.filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null)
204+
.map((branch) => ({
205+
...branch,
206+
git: processGitMetadata(branch.git),
207+
branchName: branch.branchName ?? "default",
208+
}));
209+
210+
const branchesWithActivity = await hydrateEnvsWithActivity(userId, project.id, branchesFiltered);
211+
const branchesSorted = sortEnvironments(branchesWithActivity);
212+
190213
return {
191214
branchableEnvironment,
192215
currentPage: page,
193216
totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE),
194217
hasBranches: totalBranches > 0,
195-
branches: branches.flatMap((branch) => {
196-
if (branch.branchName === null) {
197-
return [];
198-
}
199-
200-
const git = processGitMetadata(branch.git);
201-
202-
return [
203-
{
204-
...branch,
205-
branchName: branch.branchName,
206-
git,
207-
} as const,
208-
];
209-
}),
218+
branches: branchesSorted,
210219
hasFilters,
211220
limits,
212221
canPurchaseBranches,
@@ -218,6 +227,23 @@ export class BranchesPresenter {
218227
}
219228
}
220229

230+
export async function hydrateEnvsWithActivity<T extends { type: RuntimeEnvironmentType; id: string }>
231+
(userId: string, projectId: string, environments: T[]): Promise<Array<T & { lastActivity: Date | undefined; isConnected: boolean | undefined }>> {
232+
const recentDevBranchIds = await devPresence.getRecentBranchIds(userId, projectId);
233+
234+
return Promise.all(environments.map(async (env) => {
235+
if (env.type !== "DEVELOPMENT") {
236+
return { ...env, lastActivity: undefined, isConnected: undefined };
237+
}
238+
239+
const devHit = recentDevBranchIds.get(env.id);
240+
const lastActivity = devHit === undefined ? undefined : devHit;
241+
// TODO change dev-presence to a different data structure to avoid N calls?
242+
const isConnected = devHit === undefined ? undefined : await devPresence.isConnected(env.id);
243+
return { ...env, lastActivity, isConnected };
244+
}));
245+
}
246+
221247
export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null {
222248
if (!data) return null;
223249

apps/webapp/app/presenters/v3/DevPresence.server.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import Redis, { type RedisOptions } from "ioredis";
22
import { defaultReconnectOnError } from "@internal/redis";
33
import { env } from "~/env.server";
4+
import { subDays } from "date-fns";
45

5-
const PRESENCE_KEY_PREFIX = "dev-presence:connection:";
6+
const DEV_RECENT_DEBOUNCE_SEC = 60;
7+
const DEV_RECENT_TTL = 7 * 24 * 60 * 60; // 7 days
8+
const RECENCY_DAYS = 3;
69

710
export class DevPresence {
811
private redis: Redis;
@@ -17,13 +20,46 @@ export class DevPresence {
1720
return !!presenceValue;
1821
}
1922

20-
async setConnected(environmentId: string, ttl: number) {
23+
async setConnected({ userId, projectId, environmentId, ttl }: { userId: string; projectId: string; environmentId: string; ttl: number; }) {
2124
const presenceKey = this.getPresenceKey(environmentId);
2225
await this.redis.setex(presenceKey, ttl, new Date().toISOString());
26+
27+
const touchKey = this.getTouchKey(environmentId);
28+
const acquired = await this.redis.set(touchKey, "1", "EX", DEV_RECENT_DEBOUNCE_SEC, "NX");
29+
30+
if (acquired !== null) {
31+
const recentKey = this.getRecentKey(userId, projectId);
32+
const now = new Date();
33+
const threeDaysAgo = subDays(now, RECENCY_DAYS);
34+
await this.redis.zadd(recentKey, now.getTime(), environmentId);
35+
await this.redis.zremrangebyscore(recentKey, 0, threeDaysAgo.getTime());
36+
await this.redis.zremrangebyrank(recentKey, 0, -51);
37+
await this.redis.expire(recentKey, DEV_RECENT_TTL);
38+
}
39+
}
40+
41+
async getRecentBranchIds(userId: string, projectId: string) {
42+
const recentKey = this.getRecentKey(userId, projectId);
43+
const threeDaysAgo = subDays(Date.now(), RECENCY_DAYS);
44+
const raw = await this.redis.zrevrangebyscore(recentKey, "+inf", threeDaysAgo.getTime(), "WITHSCORES");
45+
46+
const branches = new Map<string, Date>();
47+
for (let i = 0; i < raw.length; i += 2) {
48+
branches.set(raw[i], new Date(Number(raw[i + 1])));
49+
}
50+
return branches;
2351
}
2452

2553
private getPresenceKey(environmentId: string) {
26-
return `${PRESENCE_KEY_PREFIX}${environmentId}`;
54+
return `dev-presence:connection:${environmentId}`;
55+
}
56+
57+
private getRecentKey(userId: string, projectId: string) {
58+
return `dev-recent:${userId}:${projectId}`;
59+
}
60+
61+
private getTouchKey(environmentId: string) {
62+
return `dev-recent-touch:${environmentId}`;
2763
}
2864
}
2965

0 commit comments

Comments
 (0)