Skip to content

Commit fe628d7

Browse files
committed
fix(webapp): make the branch env var prune tolerant of concurrent edits
The prune now selects and deletes inside one transaction and only removes a branch value row at the version it read, so a value edited in the meantime is left alone rather than deleted. Missing secret or reference rows for one key no longer abort the whole prune.
1 parent e1ac0ac commit fe628d7

2 files changed

Lines changed: 121 additions & 65 deletions

File tree

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

Lines changed: 65 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -980,83 +980,83 @@ export class EnvironmentVariablesRepository implements Repository {
980980
return { prunedKeys: [], failures: [] };
981981
}
982982

983-
const variables = await this.prismaClient.environmentVariable.findMany({
984-
where: {
985-
projectId,
986-
key: { in: boundedIn(options.keys) },
987-
project: { deletedAt: null },
988-
},
989-
select: {
990-
id: true,
991-
key: true,
992-
values: {
993-
where: { environmentId: { in: [options.environmentId, options.parentEnvironmentId] } },
994-
select: {
995-
id: true,
996-
environmentId: true,
997-
lastUpdatedBy: true,
998-
valueReference: { select: { key: true } },
999-
},
1000-
},
1001-
},
1002-
});
1003-
1004-
const shadowing: { id: string; key: string; secretReferenceKey: string | undefined }[] = [];
1005-
for (const variable of variables) {
1006-
const branchValue = variable.values.find((v) => v.environmentId === options.environmentId);
1007-
const parentValue = variable.values.find(
1008-
(v) => v.environmentId === options.parentEnvironmentId
1009-
);
1010-
if (
1011-
!branchValue ||
1012-
!parentValue ||
1013-
!isSameUpdater(branchValue.lastUpdatedBy, options.source)
1014-
) {
1015-
continue;
1016-
}
1017-
shadowing.push({
1018-
id: branchValue.id,
1019-
key: variable.key,
1020-
secretReferenceKey: branchValue.valueReference?.key,
1021-
});
1022-
}
1023-
1024-
if (shadowing.length === 0) {
1025-
return { prunedKeys: [], failures: [] };
1026-
}
1027-
1028-
const keys = shadowing.map((v) => v.key);
1029983
try {
1030-
await $transaction(this.prismaClient, "prune branch env var values", async (tx) => {
1031-
const secretStore = getSecretStore("DATABASE", {
1032-
prismaClient: tx,
1033-
});
984+
const prunedKeys = await $transaction(
985+
this.prismaClient,
986+
"prune branch env var values",
987+
async (tx) => {
988+
const variables = await tx.environmentVariable.findMany({
989+
where: {
990+
projectId,
991+
key: { in: boundedIn(options.keys) },
992+
project: { deletedAt: null },
993+
},
994+
select: {
995+
id: true,
996+
key: true,
997+
values: {
998+
where: {
999+
environmentId: { in: [options.environmentId, options.parentEnvironmentId] },
1000+
},
1001+
select: {
1002+
id: true,
1003+
version: true,
1004+
environmentId: true,
1005+
lastUpdatedBy: true,
1006+
valueReference: { select: { key: true } },
1007+
},
1008+
},
1009+
},
1010+
});
10341011

1035-
for (const value of shadowing) {
1036-
await secretStore.deleteSecret(secretKey(projectId, options.environmentId, value.key));
1012+
const pruned: string[] = [];
1013+
for (const variable of variables) {
1014+
const branchValue = variable.values.find(
1015+
(v) => v.environmentId === options.environmentId
1016+
);
1017+
const parentValue = variable.values.find(
1018+
(v) => v.environmentId === options.parentEnvironmentId
1019+
);
1020+
if (
1021+
!branchValue ||
1022+
!parentValue ||
1023+
!isSameUpdater(branchValue.lastUpdatedBy, options.source)
1024+
) {
1025+
continue;
1026+
}
10371027

1038-
if (value.secretReferenceKey) {
1039-
await tx.secretReference.delete({
1040-
where: {
1041-
key: value.secretReferenceKey,
1042-
},
1028+
const deleted = await tx.environmentVariableValue.deleteMany({
1029+
where: { id: branchValue.id, version: branchValue.version },
10431030
});
1031+
if (deleted.count === 0) {
1032+
continue;
1033+
}
1034+
1035+
if (branchValue.valueReference) {
1036+
await tx.secretReference.deleteMany({
1037+
where: { key: branchValue.valueReference.key },
1038+
});
1039+
}
1040+
await tx.secretStore.deleteMany({
1041+
where: { key: secretKey(projectId, options.environmentId, variable.key) },
1042+
});
1043+
1044+
pruned.push(variable.key);
10441045
}
10451046

1046-
await tx.environmentVariableValue.delete({
1047-
where: {
1048-
id: value.id,
1049-
},
1050-
});
1047+
return pruned;
10511048
}
1052-
});
1049+
);
10531050

1054-
return { prunedKeys: keys, failures: [] };
1051+
return { prunedKeys: prunedKeys ?? [], failures: [] };
10551052
} catch (error) {
10561053
return {
10571054
prunedKeys: [],
10581055
failures: [
1059-
{ keys, error: error instanceof Error ? error.message : "Something went wrong" },
1056+
{
1057+
keys: options.keys,
1058+
error: error instanceof Error ? error.message : "Something went wrong",
1059+
},
10601060
],
10611061
};
10621062
}

apps/webapp/test/environmentVariablesRepository.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,62 @@ describe("EnvironmentVariablesRepository.pruneBranchValuesShadowingParent", () =
994994
}
995995
);
996996

997+
postgresTest("prunes several same-source keys in one call", async ({ prisma }) => {
998+
const { project, parent, branch, repository, write, ownKeys } =
999+
await createBranchWithParent(prisma);
1000+
await write(branch.id, { A: "a-branch", B: "b-branch", C: "c-branch" }, vercel);
1001+
await write(parent.id, { A: "a-root", B: "b-root", C: "c-root" }, vercel);
1002+
1003+
const result = await repository.pruneBranchValuesShadowingParent(project.id, {
1004+
environmentId: branch.id,
1005+
parentEnvironmentId: parent.id,
1006+
keys: ["A", "B"],
1007+
source: vercel,
1008+
});
1009+
1010+
expect(result).toEqual({ prunedKeys: ["A", "B"], failures: [] });
1011+
expect(await ownKeys(branch.id)).toEqual(["C"]);
1012+
expect(await ownKeys(parent.id)).toEqual(["A", "B", "C"]);
1013+
const branchPrefix = `environmentvariable:${project.id}:${branch.id}:`;
1014+
expect(
1015+
await prisma.secretStore.findMany({ where: { key: { startsWith: branchPrefix } } })
1016+
).toHaveLength(1);
1017+
expect(
1018+
await prisma.secretReference.findMany({ where: { key: { startsWith: branchPrefix } } })
1019+
).toHaveLength(1);
1020+
expect(
1021+
Object.fromEntries(
1022+
(await repository.getEnvironmentVariables(project.id, branch.id, parent.id)).map(
1023+
({ key, value }) => [key, value]
1024+
)
1025+
)
1026+
).toEqual({ A: "a-root", B: "b-root", C: "c-branch" });
1027+
});
1028+
1029+
postgresTest("a missing secret row for one key does not stop the others", async ({ prisma }) => {
1030+
const { project, parent, branch, repository, write, ownKeys } =
1031+
await createBranchWithParent(prisma);
1032+
await write(branch.id, { A: "a", B: "b" }, vercel);
1033+
await write(parent.id, { A: "root", B: "root" }, vercel);
1034+
await prisma.secretStore.deleteMany({
1035+
where: { key: `environmentvariable:${project.id}:${branch.id}:A` },
1036+
});
1037+
await prisma.secretReference.deleteMany({
1038+
where: { key: `environmentvariable:${project.id}:${branch.id}:B` },
1039+
});
1040+
1041+
const result = await repository.pruneBranchValuesShadowingParent(project.id, {
1042+
environmentId: branch.id,
1043+
parentEnvironmentId: parent.id,
1044+
keys: ["A", "B"],
1045+
source: vercel,
1046+
});
1047+
1048+
expect(result).toEqual({ prunedKeys: ["A", "B"], failures: [] });
1049+
expect(await ownKeys(branch.id)).toEqual([]);
1050+
expect(await ownKeys(parent.id)).toEqual(["A", "B"]);
1051+
});
1052+
9971053
postgresTest("does not reach into another project's variables", async ({ prisma }) => {
9981054
const mine = await createBranchWithParent(prisma);
9991055
const theirs = await createBranchWithParent(prisma);

0 commit comments

Comments
 (0)