Skip to content

Commit a082b62

Browse files
committed
fix(webapp): remove stale branch env var copies on parent-level import
When an environment variable import to a preview branch also writes parentVariables, the branch's own value rows for those keys are now deleted if they were last written by the same source and the parent now holds a value for the key. Rows written by anyone else, keys the parent still has no value for, and keys the same request also sets on the branch itself are left alone. Prune failures are logged and never fail the import: the imported values have already been written by then.
1 parent f384e83 commit a082b62

6 files changed

Lines changed: 337 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Importing environment variables to a preview branch's parent now removes that same source's earlier copies of those variables on the branch, so the branch picks up the shared value instead of a stale override. Variables set on the branch by anyone else are left untouched.

apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
authenticateEnvVarApiRequest,
1212
authorizeEnvVarApiRequest,
1313
} from "~/services/environmentVariableApiAccess.server";
14+
import { logger } from "~/services/logger.server";
1415
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
1516

1617
const ParamsSchema = z.object({
@@ -82,6 +83,17 @@ export async function action({ params, request }: ActionFunctionArgs) {
8283
lastUpdatedBy: body.source,
8384
});
8485

86+
if (parentResult.success && body.source) {
87+
const branchKeys = new Set(Object.keys(body.variables));
88+
await pruneBranchCopies(repository, {
89+
projectId: environment.project.id,
90+
environmentId: environment.id,
91+
parentEnvironmentId: environment.parentEnvironmentId,
92+
keys: Object.keys(body.parentVariables).filter((key) => !branchKeys.has(key)),
93+
source: body.source,
94+
});
95+
}
96+
8597
let childFailure = !result.success ? result : undefined;
8698
let parentFailure = !parentResult.success ? parentResult : undefined;
8799

@@ -105,6 +117,37 @@ export async function action({ params, request }: ActionFunctionArgs) {
105117
}
106118
}
107119

120+
/**
121+
* Keys the source now writes at the parent level lose the source's own earlier copy on the
122+
* branch. A failure here never fails the import: the values are already written.
123+
*/
124+
async function pruneBranchCopies(
125+
repository: EnvironmentVariablesRepository,
126+
options: {
127+
projectId: string;
128+
environmentId: string;
129+
parentEnvironmentId: string;
130+
keys: string[];
131+
source: NonNullable<ImportEnvironmentVariablesRequestBody["source"]>;
132+
}
133+
) {
134+
const { projectId, ...pruneOptions } = options;
135+
try {
136+
const pruned = await repository.pruneBranchValuesShadowingParent(projectId, pruneOptions);
137+
if (pruned.failures.length > 0) {
138+
logger.warn("Failed to prune some branch copies of parent environment variables", {
139+
...options,
140+
failures: pruned.failures,
141+
});
142+
}
143+
} catch (error) {
144+
logger.warn("Failed to prune branch copies of parent environment variables", {
145+
...options,
146+
error: error instanceof Error ? error.message : String(error),
147+
});
148+
}
149+
}
150+
108151
async function parseImportBody(request: Request): Promise<ImportEnvironmentVariablesRequestBody> {
109152
const contentType = request.headers.get("content-type") ?? "application/json";
110153

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ import {
2727
type EditEnvironmentVariable,
2828
type EditEnvironmentVariableValue,
2929
type EnvironmentVariable,
30+
type EnvironmentVariableUpdater,
31+
EnvironmentVariableUpdaterSchema,
3032
type EnvironmentVariableWithSecret,
3133
type ProjectEnvironmentVariable,
34+
type PruneBranchValuesResult,
35+
type PruneBranchValuesShadowingParent,
3236
type Repository,
3337
type Result,
3438
} from "./repository";
@@ -56,6 +60,20 @@ function parseSecretKey(key: string) {
5660

5761
const SecretValue = z.object({ secret: z.string() });
5862

63+
function isSameUpdater(stored: unknown, source: EnvironmentVariableUpdater): boolean {
64+
const parsed = EnvironmentVariableUpdaterSchema.safeParse(stored);
65+
if (!parsed.success) {
66+
return false;
67+
}
68+
if (parsed.data.type === "user" && source.type === "user") {
69+
return parsed.data.userId === source.userId;
70+
}
71+
if (parsed.data.type === "integration" && source.type === "integration") {
72+
return parsed.data.integration === source.integration;
73+
}
74+
return false;
75+
}
76+
5977
export class EnvironmentVariablesRepository implements Repository {
6078
constructor(
6179
private prismaClient: PrismaClient = prisma,
@@ -952,6 +970,58 @@ export class EnvironmentVariablesRepository implements Repository {
952970
};
953971
}
954972
}
973+
974+
async pruneBranchValuesShadowingParent(
975+
projectId: string,
976+
options: PruneBranchValuesShadowingParent
977+
): Promise<PruneBranchValuesResult> {
978+
const result: PruneBranchValuesResult = { prunedKeys: [], failures: [] };
979+
if (options.keys.length === 0) {
980+
return result;
981+
}
982+
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: { environmentId: true, lastUpdatedBy: true },
995+
},
996+
},
997+
});
998+
999+
for (const variable of variables) {
1000+
const branchValue = variable.values.find((v) => v.environmentId === options.environmentId);
1001+
const parentValue = variable.values.find(
1002+
(v) => v.environmentId === options.parentEnvironmentId
1003+
);
1004+
if (
1005+
!branchValue ||
1006+
!parentValue ||
1007+
!isSameUpdater(branchValue.lastUpdatedBy, options.source)
1008+
) {
1009+
continue;
1010+
}
1011+
1012+
const deleted = await this.deleteValue(projectId, {
1013+
id: variable.id,
1014+
environmentId: options.environmentId,
1015+
});
1016+
if (deleted.success) {
1017+
result.prunedKeys.push(variable.key);
1018+
} else {
1019+
result.failures.push({ key: variable.key, error: deleted.error });
1020+
}
1021+
}
1022+
1023+
return result;
1024+
}
9551025
}
9561026

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

apps/webapp/app/v3/environmentVariables/repository.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const EnvironmentVariableKey = z
66
.nonempty("Key is required")
77
.regex(/^\w+$/, "Keys can only use alphanumeric characters and underscores");
88

9-
const EnvironmentVariableUpdaterSchema = z.discriminatedUnion("type", [
9+
export const EnvironmentVariableUpdaterSchema = z.discriminatedUnion("type", [
1010
z.object({
1111
type: z.literal("user"),
1212
userId: z.string(),
@@ -63,6 +63,18 @@ export const DeleteEnvironmentVariableValue = z.object({
6363
});
6464
export type DeleteEnvironmentVariableValue = z.infer<typeof DeleteEnvironmentVariableValue>;
6565

66+
export type PruneBranchValuesShadowingParent = {
67+
environmentId: string;
68+
parentEnvironmentId: string;
69+
keys: string[];
70+
source: EnvironmentVariableUpdater;
71+
};
72+
73+
export type PruneBranchValuesResult = {
74+
prunedKeys: string[];
75+
failures: { key: string; error: string }[];
76+
};
77+
6678
// Forms preserve explicit empty strings through their custom coercion.
6779
// A missing field is still invalid.
6880
export const EnvironmentVariableValue = z.string();
@@ -140,4 +152,14 @@ export interface Repository {
140152
): Promise<EnvironmentVariable[]>;
141153
delete(projectId: string, options: DeleteEnvironmentVariable): Promise<Result>;
142154
deleteValue(projectId: string, options: DeleteEnvironmentVariableValue): Promise<Result>;
155+
/**
156+
* A source that writes a key at the parent level retracts its own earlier copy of that key on
157+
* the branch. Deletes the branch's own value rows for `keys` that were last written by `source`
158+
* and that now shadow a value the parent holds. Rows written by anyone else, and rows whose key
159+
* the parent has no value for, are untouched.
160+
*/
161+
pruneBranchValuesShadowingParent(
162+
projectId: string,
163+
options: PruneBranchValuesShadowingParent
164+
): Promise<PruneBranchValuesResult>;
143165
}

0 commit comments

Comments
 (0)