Skip to content

Commit bcfd008

Browse files
committed
feat(webapp,core): bulk delete endpoint for environment variables
POST /api/v1/projects/:projectRef/envvars/:slug/bulk-delete removes up to 1000 variables from one environment in a single call. The body can narrow the delete to values last written by a given source (onlyWrittenBy) or to branch values whose key also has a value on the parent environment (onlyShadowingParent); the response lists the keys deleted and the keys skipped. All value removal now goes through one helper that deletes the value rows, their secret references and secret store entries in a fixed number of statements for any number of keys, drops a variable left with no values, and skips a value that changed while the delete ran. The single-value delete uses the same path.
1 parent 230b5b0 commit bcfd008

7 files changed

Lines changed: 586 additions & 36 deletions

File tree

.changeset/envvar-bulk-delete.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Add a bulk delete endpoint for environment variables, `POST /api/v1/projects/:projectRef/envvars/:slug/bulk-delete`, which removes up to 1000 variables from one environment in a single call and can be limited to values last written by a given source or to branch values that shadow a value on the parent environment.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
2+
import { json } from "@remix-run/server-runtime";
3+
import {
4+
BulkDeleteEnvironmentVariablesRequestBody,
5+
type BulkDeleteEnvironmentVariablesResponseBody,
6+
} from "@trigger.dev/core/v3";
7+
import { z } from "zod";
8+
import {
9+
authenticatedEnvironmentForAuthentication,
10+
branchNameFromRequest,
11+
} from "~/services/apiAuth.server";
12+
import {
13+
authenticateEnvVarApiRequest,
14+
authorizeEnvVarApiRequest,
15+
} from "~/services/environmentVariableApiAccess.server";
16+
import { logger } from "~/services/logger.server";
17+
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
18+
19+
const ParamsSchema = z.object({
20+
projectRef: z.string(),
21+
slug: z.string(),
22+
});
23+
24+
export async function action({ params, request }: ActionFunctionArgs) {
25+
if (request.method.toUpperCase() !== "POST") {
26+
return json({ error: "Method not allowed" }, { status: 405 });
27+
}
28+
29+
const parsedParams = ParamsSchema.safeParse(params);
30+
31+
if (!parsedParams.success) {
32+
return json({ error: "Invalid params" }, { status: 400 });
33+
}
34+
35+
try {
36+
const authResult = await authenticateEnvVarApiRequest(request, "write");
37+
if (!authResult.ok) {
38+
return json({ error: authResult.error }, { status: authResult.status });
39+
}
40+
const authenticationResult = authResult.authentication;
41+
42+
const environment = await authenticatedEnvironmentForAuthentication(
43+
authenticationResult,
44+
parsedParams.data.projectRef,
45+
parsedParams.data.slug,
46+
branchNameFromRequest(request)
47+
);
48+
49+
const denied = await authorizeEnvVarApiRequest({
50+
request,
51+
authType: authenticationResult.type,
52+
ability:
53+
authenticationResult.type === "apiKey" && authenticationResult.result.ok
54+
? authenticationResult.result.ability
55+
: undefined,
56+
organizationId: environment.organizationId,
57+
projectId: environment.project.id,
58+
envType: environment.type,
59+
action: "write",
60+
});
61+
if (denied) return denied;
62+
63+
const rawBody = await request.json().catch(() => undefined);
64+
const body = BulkDeleteEnvironmentVariablesRequestBody.safeParse(rawBody);
65+
66+
if (!body.success) {
67+
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
68+
}
69+
70+
const repository = new EnvironmentVariablesRepository();
71+
72+
const result: BulkDeleteEnvironmentVariablesResponseBody = await repository.deleteValues(
73+
environment.project.id,
74+
{
75+
environmentId: environment.id,
76+
keys: body.data.keys,
77+
onlyWrittenBy: body.data.onlyWrittenBy,
78+
onlyShadowingParent: body.data.onlyShadowingParent,
79+
}
80+
);
81+
82+
return json(result);
83+
} catch (error) {
84+
if (error instanceof Response) throw error;
85+
logger.error("Failed to bulk delete environment variables", {
86+
error,
87+
projectRef: params.projectRef,
88+
});
89+
return json({ error: "Internal Server Error" }, { status: 500 });
90+
}
91+
}

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 200 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
boundedIn,
88
Prisma,
99
type PrismaClient,
10+
type PrismaClientOrTransaction,
1011
type RuntimeEnvironmentType,
1112
} from "@trigger.dev/database";
1213
import { z } from "zod";
@@ -24,9 +25,13 @@ import {
2425
type CreateResult,
2526
type DeleteEnvironmentVariable,
2627
type DeleteEnvironmentVariableValue,
28+
type DeleteEnvironmentVariableValues,
29+
type DeleteEnvironmentVariableValuesResult,
2730
type EditEnvironmentVariable,
2831
type EditEnvironmentVariableValue,
2932
type EnvironmentVariable,
33+
type EnvironmentVariableUpdater,
34+
EnvironmentVariableUpdaterSchema,
3035
type EnvironmentVariableWithSecret,
3136
type ProjectEnvironmentVariable,
3237
type Repository,
@@ -56,6 +61,97 @@ function parseSecretKey(key: string) {
5661

5762
const SecretValue = z.object({ secret: z.string() });
5863

64+
function isSameUpdater(stored: unknown, source: EnvironmentVariableUpdater): boolean {
65+
const parsed = EnvironmentVariableUpdaterSchema.safeParse(stored);
66+
if (!parsed.success) {
67+
return false;
68+
}
69+
if (parsed.data.type === "user" && source.type === "user") {
70+
return parsed.data.userId === source.userId;
71+
}
72+
if (parsed.data.type === "integration" && source.type === "integration") {
73+
return parsed.data.integration === source.integration;
74+
}
75+
return false;
76+
}
77+
78+
export type EnvironmentVariableValueRow = {
79+
id: string;
80+
/** When set, the row is only removed if it still has this version. */
81+
version?: number;
82+
variableId: string;
83+
key: string;
84+
secretReferenceKey?: string;
85+
};
86+
87+
/**
88+
* The single code path that removes value rows of one environment together with their secret
89+
* store entries and secret references, using a fixed number of statements for any number of
90+
* rows. A variable left with no values afterwards is removed as well.
91+
*/
92+
export async function deleteEnvironmentVariableValueRows(
93+
tx: PrismaClientOrTransaction,
94+
projectId: string,
95+
environmentId: string,
96+
rows: EnvironmentVariableValueRow[]
97+
): Promise<{ deleted: EnvironmentVariableValueRow[]; skipped: EnvironmentVariableValueRow[] }> {
98+
if (rows.length === 0) {
99+
return { deleted: [], skipped: [] };
100+
}
101+
102+
const removed = await tx.environmentVariableValue.deleteMany({
103+
where: {
104+
OR: rows.map((row) =>
105+
row.version === undefined ? { id: row.id } : { id: row.id, version: row.version }
106+
),
107+
},
108+
});
109+
110+
let deleted = rows;
111+
let skipped: EnvironmentVariableValueRow[] = [];
112+
if (removed.count < rows.length) {
113+
const survivors = await tx.environmentVariableValue.findMany({
114+
where: { id: { in: boundedIn(rows.map((row) => row.id)) } },
115+
select: { id: true },
116+
});
117+
const survivorIds = new Set(survivors.map((value) => value.id));
118+
deleted = rows.filter((row) => !survivorIds.has(row.id));
119+
skipped = rows.filter((row) => survivorIds.has(row.id));
120+
}
121+
122+
if (deleted.length === 0) {
123+
return { deleted, skipped };
124+
}
125+
126+
const referenceKeys = deleted.flatMap((row) =>
127+
row.secretReferenceKey ? [row.secretReferenceKey] : []
128+
);
129+
if (referenceKeys.length > 0) {
130+
await tx.secretReference.deleteMany({ where: { key: { in: boundedIn(referenceKeys) } } });
131+
}
132+
133+
await tx.secretStore.deleteMany({
134+
where: {
135+
key: { in: boundedIn(deleted.map((row) => secretKey(projectId, environmentId, row.key))) },
136+
},
137+
});
138+
139+
const emptied = await tx.environmentVariable.findMany({
140+
where: {
141+
id: { in: boundedIn(deleted.map((row) => row.variableId)) },
142+
values: { none: {} },
143+
},
144+
select: { id: true },
145+
});
146+
if (emptied.length > 0) {
147+
await tx.environmentVariable.deleteMany({
148+
where: { id: { in: boundedIn(emptied.map((variable) => variable.id)) } },
149+
});
150+
}
151+
152+
return { deleted, skipped };
153+
}
154+
59155
export class EnvironmentVariablesRepository implements Repository {
60156
constructor(
61157
private prismaClient: PrismaClient = prisma,
@@ -870,11 +966,7 @@ export class EnvironmentVariablesRepository implements Repository {
870966
deletedAt: null,
871967
},
872968
select: {
873-
environments: {
874-
select: {
875-
id: true,
876-
},
877-
},
969+
id: true,
878970
},
879971
});
880972

@@ -887,9 +979,11 @@ export class EnvironmentVariablesRepository implements Repository {
887979
id: true,
888980
key: true,
889981
values: {
982+
where: {
983+
environmentId: options.environmentId,
984+
},
890985
select: {
891986
id: true,
892-
environmentId: true,
893987
valueReference: {
894988
select: {
895989
key: true,
@@ -900,46 +994,30 @@ export class EnvironmentVariablesRepository implements Repository {
900994
},
901995
where: {
902996
id: options.id,
997+
projectId,
903998
},
904999
});
9051000

9061001
if (!environmentVariable) {
9071002
return { success: false as const, error: "Environment variable not found" };
9081003
}
9091004

910-
const value = environmentVariable.values.find((v) => v.environmentId === options.environmentId);
1005+
const value = environmentVariable.values[0];
9111006

9121007
if (!value) {
9131008
return { success: false as const, error: "Environment variable value not found" };
9141009
}
9151010

916-
// If this is the last value, delete the whole variable
917-
if (environmentVariable.values.length === 1) {
918-
return this.delete(projectId, { id: options.id });
919-
}
920-
9211011
try {
9221012
await $transaction(this.prismaClient, "delete env var value", async (tx) => {
923-
const secretStore = getSecretStore("DATABASE", {
924-
prismaClient: tx,
925-
});
926-
927-
const key = secretKey(projectId, options.environmentId, environmentVariable.key);
928-
await secretStore.deleteSecret(key);
929-
930-
if (value.valueReference) {
931-
await tx.secretReference.delete({
932-
where: {
933-
key: value.valueReference.key,
934-
},
935-
});
936-
}
937-
938-
await tx.environmentVariableValue.delete({
939-
where: {
1013+
await deleteEnvironmentVariableValueRows(tx, projectId, options.environmentId, [
1014+
{
9401015
id: value.id,
1016+
variableId: environmentVariable.id,
1017+
key: environmentVariable.key,
1018+
secretReferenceKey: value.valueReference?.key,
9411019
},
942-
});
1020+
]);
9431021
});
9441022

9451023
return {
@@ -952,6 +1030,98 @@ export class EnvironmentVariablesRepository implements Repository {
9521030
};
9531031
}
9541032
}
1033+
1034+
async deleteValues(
1035+
projectId: string,
1036+
options: DeleteEnvironmentVariableValues
1037+
): Promise<DeleteEnvironmentVariableValuesResult> {
1038+
const keys = Array.from(new Set(options.keys));
1039+
if (keys.length === 0) {
1040+
return { deleted: [], skipped: [] };
1041+
}
1042+
1043+
const deletedKeys = await $transaction(
1044+
this.prismaClient,
1045+
"delete env var values",
1046+
async (tx) => {
1047+
let parentEnvironmentId: string | undefined;
1048+
if (options.onlyShadowingParent) {
1049+
const environment = await tx.runtimeEnvironment.findFirst({
1050+
where: { id: options.environmentId, projectId },
1051+
select: { parentEnvironmentId: true },
1052+
});
1053+
parentEnvironmentId = environment?.parentEnvironmentId ?? undefined;
1054+
if (!parentEnvironmentId) {
1055+
return [];
1056+
}
1057+
}
1058+
1059+
const environmentIds = parentEnvironmentId
1060+
? [options.environmentId, parentEnvironmentId]
1061+
: [options.environmentId];
1062+
1063+
const variables = await tx.environmentVariable.findMany({
1064+
where: {
1065+
projectId,
1066+
key: { in: boundedIn(keys) },
1067+
project: { deletedAt: null },
1068+
},
1069+
select: {
1070+
id: true,
1071+
key: true,
1072+
values: {
1073+
where: { environmentId: { in: boundedIn(environmentIds) } },
1074+
select: {
1075+
id: true,
1076+
version: true,
1077+
environmentId: true,
1078+
lastUpdatedBy: true,
1079+
valueReference: { select: { key: true } },
1080+
},
1081+
},
1082+
},
1083+
});
1084+
1085+
const rows: EnvironmentVariableValueRow[] = [];
1086+
for (const variable of variables) {
1087+
const own = variable.values.find((v) => v.environmentId === options.environmentId);
1088+
if (!own) {
1089+
continue;
1090+
}
1091+
if (options.onlyWrittenBy && !isSameUpdater(own.lastUpdatedBy, options.onlyWrittenBy)) {
1092+
continue;
1093+
}
1094+
if (
1095+
parentEnvironmentId &&
1096+
!variable.values.some((v) => v.environmentId === parentEnvironmentId)
1097+
) {
1098+
continue;
1099+
}
1100+
rows.push({
1101+
id: own.id,
1102+
version: own.version,
1103+
variableId: variable.id,
1104+
key: variable.key,
1105+
secretReferenceKey: own.valueReference?.key,
1106+
});
1107+
}
1108+
1109+
const { deleted } = await deleteEnvironmentVariableValueRows(
1110+
tx,
1111+
projectId,
1112+
options.environmentId,
1113+
rows
1114+
);
1115+
return deleted.map((row) => row.key);
1116+
}
1117+
);
1118+
1119+
const deleted = new Set(deletedKeys ?? []);
1120+
return {
1121+
deleted: keys.filter((key) => deleted.has(key)),
1122+
skipped: keys.filter((key) => !deleted.has(key)),
1123+
};
1124+
}
9551125
}
9561126

9571127
// Derived from the slim AuthenticatedEnvironment so a full AE satisfies

0 commit comments

Comments
 (0)