Skip to content

Commit 759240c

Browse files
committed
fix(webapp): keep env var value deletes to a fixed statement shape
The shared value delete pads its per-row OR arms to a power of two so a call site does not mint one prepared statement per distinct row count, folds the emptied-variable sweep into one conditional delete, and takes the environment id per row so a caller cannot mix environments and clear the wrong secret store entry. Adds behavioural tests for the single-value delete path (last value drops the variable, other environments keep theirs, a value without a secret reference deletes cleanly) and for skipped duplicate and already-removed keys in the bulk delete.
1 parent bcfd008 commit 759240c

2 files changed

Lines changed: 101 additions & 22 deletions

File tree

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

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,21 @@ export type EnvironmentVariableValueRow = {
8080
/** When set, the row is only removed if it still has this version. */
8181
version?: number;
8282
variableId: string;
83+
environmentId: string;
8384
key: string;
8485
secretReferenceKey?: string;
8586
};
8687

8788
/**
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.
89+
* The single code path that removes value rows together with their secret store entries and
90+
* secret references, in a fixed number of statements for any number of rows. A variable left
91+
* with no values afterwards is removed as well; under READ COMMITTED a value created for that
92+
* variable at the same moment can still be swept away with it, a pre-existing window that this
93+
* narrows but does not close.
9194
*/
9295
export async function deleteEnvironmentVariableValueRows(
9396
tx: PrismaClientOrTransaction,
9497
projectId: string,
95-
environmentId: string,
9698
rows: EnvironmentVariableValueRow[]
9799
): Promise<{ deleted: EnvironmentVariableValueRow[]; skipped: EnvironmentVariableValueRow[] }> {
98100
if (rows.length === 0) {
@@ -101,7 +103,7 @@ export async function deleteEnvironmentVariableValueRows(
101103

102104
const removed = await tx.environmentVariableValue.deleteMany({
103105
where: {
104-
OR: rows.map((row) =>
106+
OR: boundedIn(rows).map((row) =>
105107
row.version === undefined ? { id: row.id } : { id: row.id, version: row.version }
106108
),
107109
},
@@ -132,22 +134,18 @@ export async function deleteEnvironmentVariableValueRows(
132134

133135
await tx.secretStore.deleteMany({
134136
where: {
135-
key: { in: boundedIn(deleted.map((row) => secretKey(projectId, environmentId, row.key))) },
137+
key: {
138+
in: boundedIn(deleted.map((row) => secretKey(projectId, row.environmentId, row.key))),
139+
},
136140
},
137141
});
138142

139-
const emptied = await tx.environmentVariable.findMany({
143+
await tx.environmentVariable.deleteMany({
140144
where: {
141145
id: { in: boundedIn(deleted.map((row) => row.variableId)) },
142146
values: { none: {} },
143147
},
144-
select: { id: true },
145148
});
146-
if (emptied.length > 0) {
147-
await tx.environmentVariable.deleteMany({
148-
where: { id: { in: boundedIn(emptied.map((variable) => variable.id)) } },
149-
});
150-
}
151149

152150
return { deleted, skipped };
153151
}
@@ -1010,10 +1008,11 @@ export class EnvironmentVariablesRepository implements Repository {
10101008

10111009
try {
10121010
await $transaction(this.prismaClient, "delete env var value", async (tx) => {
1013-
await deleteEnvironmentVariableValueRows(tx, projectId, options.environmentId, [
1011+
await deleteEnvironmentVariableValueRows(tx, projectId, [
10141012
{
10151013
id: value.id,
10161014
variableId: environmentVariable.id,
1015+
environmentId: options.environmentId,
10171016
key: environmentVariable.key,
10181017
secretReferenceKey: value.valueReference?.key,
10191018
},
@@ -1101,17 +1100,13 @@ export class EnvironmentVariablesRepository implements Repository {
11011100
id: own.id,
11021101
version: own.version,
11031102
variableId: variable.id,
1103+
environmentId: options.environmentId,
11041104
key: variable.key,
11051105
secretReferenceKey: own.valueReference?.key,
11061106
});
11071107
}
11081108

1109-
const { deleted } = await deleteEnvironmentVariableValueRows(
1110-
tx,
1111-
projectId,
1112-
options.environmentId,
1113-
rows
1114-
);
1109+
const { deleted } = await deleteEnvironmentVariableValueRows(tx, projectId, rows);
11151110
return deleted.map((row) => row.key);
11161111
}
11171112
);

apps/webapp/test/environmentVariablesRepository.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,7 @@ postgresTest(
765765
}
766766
);
767767

768-
describe("EnvironmentVariablesRepository.deleteValues", () => {
768+
describe("EnvironmentVariablesRepository value deletes", () => {
769769
const vercel = { type: "integration" as const, integration: "vercel" };
770770

771771
async function createBranchWithParent(
@@ -954,12 +954,13 @@ describe("EnvironmentVariablesRepository.deleteValues", () => {
954954
id: value.id,
955955
version: value.variable.key === "STALE" ? value.version + 1 : value.version,
956956
variableId: value.variableId,
957+
environmentId: branch.id,
957958
key: value.variable.key,
958959
secretReferenceKey: value.valueReference?.key,
959960
}));
960961

961962
const result = await prisma.$transaction((tx) =>
962-
deleteEnvironmentVariableValueRows(tx, project.id, branch.id, rows)
963+
deleteEnvironmentVariableValueRows(tx, project.id, rows)
963964
);
964965

965966
expect(result.deleted.map((r) => r.key)).toEqual(["FRESH"]);
@@ -994,4 +995,87 @@ describe("EnvironmentVariablesRepository.deleteValues", () => {
994995
).toEqual({ deleted: [], skipped: ["SHARED"] });
995996
expect(await theirs.ownKeys(theirs.branch.id)).toEqual(["SHARED"]);
996997
});
998+
postgresTest("skips duplicate keys and keys whose row is already gone", async ({ prisma }) => {
999+
const { project, branch, repository, write, ownKeys } = await createBranchWithParent(prisma);
1000+
await write(branch.id, { GONE: "g", KEPT: "k" }, vercel);
1001+
await prisma.environmentVariableValue.deleteMany({
1002+
where: { environmentId: branch.id, variable: { key: "GONE" } },
1003+
});
1004+
1005+
expect(
1006+
await repository.deleteValues(project.id, {
1007+
environmentId: branch.id,
1008+
keys: ["GONE", "GONE", "KEPT", "KEPT", "NEVER"],
1009+
})
1010+
).toEqual({ deleted: ["KEPT"], skipped: ["GONE", "NEVER"] });
1011+
expect(await ownKeys(branch.id)).toEqual([]);
1012+
});
1013+
1014+
postgresTest(
1015+
"deleteValue removes the variable with its last value and its secret rows",
1016+
async ({ prisma }) => {
1017+
const { project, branch, repository, write, variableKeys, secretRows } =
1018+
await createBranchWithParent(prisma);
1019+
await write(branch.id, { ONLY: "o", OTHER: "x" }, vercel);
1020+
const variable = await prisma.environmentVariable.findFirstOrThrow({
1021+
where: { projectId: project.id, key: "ONLY" },
1022+
});
1023+
1024+
expect(
1025+
await repository.deleteValue(project.id, { id: variable.id, environmentId: branch.id })
1026+
).toEqual({ success: true });
1027+
expect(await variableKeys()).toEqual(["OTHER"]);
1028+
expect(await secretRows(branch.id)).toEqual({ store: ["OTHER"], references: ["OTHER"] });
1029+
expect(
1030+
await repository.deleteValue(project.id, { id: variable.id, environmentId: branch.id })
1031+
).toEqual({ success: false, error: "Environment variable not found" });
1032+
}
1033+
);
1034+
1035+
postgresTest(
1036+
"deleteValue keeps the value the variable has in another environment",
1037+
async ({ prisma }) => {
1038+
const { project, parent, branch, repository, write, ownKeys, variableKeys, secretRows } =
1039+
await createBranchWithParent(prisma);
1040+
await write(branch.id, { BOTH: "branch" }, vercel);
1041+
await write(parent.id, { BOTH: "root" }, vercel);
1042+
const variable = await prisma.environmentVariable.findFirstOrThrow({
1043+
where: { projectId: project.id, key: "BOTH" },
1044+
});
1045+
1046+
expect(
1047+
await repository.deleteValue(project.id, { id: variable.id, environmentId: branch.id })
1048+
).toEqual({ success: true });
1049+
expect(await variableKeys()).toEqual(["BOTH"]);
1050+
expect(await ownKeys(branch.id)).toEqual([]);
1051+
expect(await ownKeys(parent.id)).toEqual(["BOTH"]);
1052+
expect(await secretRows(branch.id)).toEqual({ store: [], references: [] });
1053+
expect(await secretRows(parent.id)).toEqual({ store: ["BOTH"], references: ["BOTH"] });
1054+
expect(await repository.getEnvironmentVariables(project.id, branch.id, parent.id)).toEqual([
1055+
{ key: "BOTH", value: "root" },
1056+
]);
1057+
expect(
1058+
await repository.deleteValue(project.id, { id: variable.id, environmentId: branch.id })
1059+
).toEqual({ success: false, error: "Environment variable value not found" });
1060+
}
1061+
);
1062+
1063+
postgresTest("deleteValue handles a value without a secret reference", async ({ prisma }) => {
1064+
const { project, branch, repository, write, variableKeys, secretRows } =
1065+
await createBranchWithParent(prisma);
1066+
await write(branch.id, { UNLINKED: "u" }, vercel);
1067+
const variable = await prisma.environmentVariable.findFirstOrThrow({
1068+
where: { projectId: project.id, key: "UNLINKED" },
1069+
});
1070+
await prisma.environmentVariableValue.updateMany({
1071+
where: { variableId: variable.id, environmentId: branch.id },
1072+
data: { valueReferenceId: null },
1073+
});
1074+
1075+
expect(
1076+
await repository.deleteValue(project.id, { id: variable.id, environmentId: branch.id })
1077+
).toEqual({ success: true });
1078+
expect(await variableKeys()).toEqual([]);
1079+
expect((await secretRows(branch.id)).store).toEqual([]);
1080+
});
9971081
});

0 commit comments

Comments
 (0)