Skip to content

Commit c6f2e21

Browse files
fix(env): scope branch-override lookup by branchId (#109)
## Problem `project env update --branch` and `project env add --branch` fail contradictorily on any branch that already has an override for a key: - `add --branch` → server **409** "already exists in this scope" - `update --branch` → CLI **`ENV_VARIABLE_NOT_FOUND`** "not found in branch:…" One says the variable exists, the other says it doesn't. Neither path can touch an existing branch override, so re-running a workflow that sets a per-branch override hard-fails. ## Root cause `findVariableByNaturalKey` listed environment variables by `(projectId, class, key)` and filtered by branch **client-side**, inspecting only the first page. `GET /v1/environment-variables` is paginated (default `limit=100`, ordered `createdAt asc`). Once a project accumulates more than a page of preview overrides for a given key, the *newest* branch's row sorts onto a later page and the lookup returns `null` even though the row exists on the server. Downstream: - `update`'s pre-lookup returns null → throws `ENV_VARIABLE_NOT_FOUND` before it ever PATCHes. - `add`'s existence pre-check also returns null → it POSTs → the server (which enforces uniqueness on `projectId, branchId, class, key`) rejects with 409. The endpoint already supports a `branchId` filter — the lookup just wasn't using it. ## Why this surfaced now This is a latent bug (the branch-override lookup has never passed `branchId`), triggered by data volume rather than a code change. In one affected project, the count of preview-class rows for `BUILD_RUNNER_BASE_URL` had just crossed the page limit (104 rows, 104 distinct branches, 4 past the first page) — so the freshly-created branch override was the row that fell off page 1. First deploy of a branch (which takes the `add`/create path) still worked; every re-deploy (the lookup path) failed. That first-run-vs-rerun signature is exactly what the pagination boundary produces. ## Fix Pass `branchId` to the list query for branch-scoped lookups so the server returns the single exact row regardless of how many branches exist. Role-scoped lookups (`branchId === null`) are unchanged. ## Testing - `pnpm --filter @prisma/cli exec tsc --noEmit` — clean - `pnpm --filter @prisma/cli test` — 563 passed - Added a regression test asserting the branch-override lookup sends `branchId`; verified it fails without the source change and passes with it. ## Known residual (out of scope) Role-scoped (template) lookups still fetch a single page and filter `branchId === null` client-side — the same pagination class of bug in theory — but the list query type only accepts `branchId?: string`, so there's no way to express "branchId IS NULL" as a server filter today, and template rows are singular per key and created early (page 1). Worth a follow-up if the API grows a null-branch filter. Separately, the projects hitting this also have a data-hygiene problem: preview-branch overrides (and their branches/databases) are never cleaned up, which is what let the row count climb past the page limit in the first place. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f152fe3 commit c6f2e21

2 files changed

Lines changed: 65 additions & 0 deletions

File tree

packages/cli/src/controllers/app-env-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export async function findVariableByNaturalKey(
3333
resolved: ResolvedEnvApiScope,
3434
signal: AbortSignal,
3535
): Promise<RawEnvironmentVariable | null> {
36+
// Filter by branchId server-side — the list pages at 100, so client-side-only filtering drops branch rows past page 1.
3637
const { data, error, response } = await client.GET(
3738
"/v1/environment-variables",
3839
{
@@ -41,6 +42,9 @@ export async function findVariableByNaturalKey(
4142
projectId,
4243
class: resolved.apiTarget.class,
4344
key,
45+
...(resolved.apiTarget.branchId !== null
46+
? { branchId: resolved.apiTarget.branchId }
47+
: {}),
4448
},
4549
},
4650
signal,

packages/cli/tests/app-env.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,67 @@ describe("env update", () => {
944944
);
945945
});
946946

947+
it("scopes the branch-override lookup by branchId so it survives pagination", async () => {
948+
const client = createMockClient();
949+
client.envGET
950+
.mockResolvedValueOnce({
951+
data: {
952+
data: [makeBranchRow()],
953+
pagination: { hasMore: false, nextCursor: null },
954+
},
955+
response: { status: 200 },
956+
})
957+
.mockResolvedValueOnce({
958+
data: {
959+
data: [
960+
makeVariableRow({
961+
id: "envvar_branch",
962+
key: "DATABASE_URL",
963+
class: "preview",
964+
branchId: "br_feature",
965+
}),
966+
],
967+
pagination: { hasMore: false, nextCursor: null },
968+
},
969+
response: { status: 200 },
970+
});
971+
client.PATCH.mockResolvedValueOnce({
972+
data: {
973+
data: makeVariableRow({
974+
id: "envvar_branch",
975+
key: "DATABASE_URL",
976+
class: "preview",
977+
branchId: "br_feature",
978+
}),
979+
},
980+
response: { status: 200 },
981+
});
982+
983+
const { controllers, createTempCwd, createTestCommandContext } =
984+
await loadControllers(client, "proj_123");
985+
const cwd = await createTempCwd();
986+
await writeLocalPin(cwd);
987+
const { context } = await createTestCommandContext({ cwd });
988+
989+
await controllers.runEnvUpdate(context, "DATABASE_URL=postgresql://new", {
990+
branchName: "feature/foo",
991+
});
992+
993+
expect(client.GET).toHaveBeenCalledWith(
994+
"/v1/environment-variables",
995+
expect.objectContaining({
996+
params: {
997+
query: expect.objectContaining({
998+
projectId: "proj_123",
999+
class: "preview",
1000+
key: "DATABASE_URL",
1001+
branchId: "br_feature",
1002+
}),
1003+
},
1004+
}),
1005+
);
1006+
});
1007+
9471008
it("updates variables from a dotenv file via PATCH", async () => {
9481009
const client = createMockClient();
9491010
client.envGET

0 commit comments

Comments
 (0)