1import * as Effect from "effect/Effect";
2import { Unowned } from "../AdoptPolicy.ts";
3import { isResolved } from "../Diff.ts";
4import { createPhysicalName } from "../PhysicalName.ts";
5import * as Provider from "../Provider.ts";
6import {
7 DEV_TIMESTAMP,
8 attrOrString,
9 devId,
10 devProvider,
11} from "./Internal/DevStub.ts";
12import * as ProviderLayer from "../Local/ProviderLayer.ts";
13import { Resource } from "../Resource.ts";
14import {
15 type GetProjectBranchesResponse,
16 type GetProjectsResponse,
17 deleteBranch,
18 getBranch,
19 getProjects,
20 getProjectBranches,
21 updateBranch,
22 createProjectBranch,
23} from "@distilled.cloud/prisma/management";
24import { Retry } from "@distilled.cloud/prisma";
25import type { Project } from "./Project.ts";
26import type { Providers } from "./Providers.ts";
27import {
28 concreteIdsChanged,
29 isInputObject,
30 isPrismaDevId,
31 resolveProjectId,
32 unresolvedProjectIdOf,
33} from "./Refs.ts";
34import type { ObservedBranch } from "./Internal/Observed.ts";
35import { PrismaPaginationError } from "./Internal/Pagination.ts";
36
37export interface BranchProps {
38 /**
39 * Project ID or `project.projectId` output that owns this branch.
40 */
41 project: string | Project;
42 /**
43 * Git-style branch name. If omitted, Alchemy generates a stable physical
44 * name.
45 */
46 gitName?: string;
47 /**
48 * Promote this branch to be the project's default branch. Setting this to
49 * `false` does not demote a current default because the Management API only
50 * supports promotion; promoting another branch atomically demotes it.
51 * Promotion changes `isDefault`, not the branch's immutable `preview` role.
52 *
53 * @default false
54 */
55 isDefault?: boolean;
56}
57
58export interface Branch extends Resource<
59 "Prisma.Branch",
60 BranchProps,
61 {
62 /**
63 * Prisma branch ID.
64 */
65 branchId: string;
66 /**
67 * Git-style branch name.
68 */
69 gitName: string;
70 /**
71 * Project ID that owns the branch.
72 */
73 projectId: string;
74 /**
75 * Whether this branch is the project's default branch.
76 */
77 isDefault: boolean;
78 /**
79 * Branch that was default before this preview branch was promoted. Alchemy
80 * restores it before deleting this resource so destroy remains reversible.
81 */
82 previousDefaultBranchId?: string;
83 /**
84 * Branch role used by Prisma to resolve deploy-time environment variables.
85 */
86 role: "production" | "preview";
87 /**
88 * ISO timestamp when the branch was created.
89 */
90 createdAt: string;
91 /**
92 * ISO timestamp when the branch was last updated.
93 */
94 updatedAt: string;
95 },
96 never,
97 Providers
98> {}
99
100/**
101 * A Prisma project branch for preview-class databases and compute resources.
102 *
103 * Standalone Branch resources always have the `preview` role. Promotion
104 * changes only the default branch; Alchemy restores the previous default
105 * before deleting a promoted branch.
106 *
107 * ### Creating a Branch
108 * **Example:** Preview branch
109 * ```typescript
110 * const branch = yield* Prisma.Branch("preview", {
111 * project: project.projectId,
112 * gitName: "feature/search", // optional — omitted, a stable name is generated
113 * });
114 *
115 * branch.role; // "preview"
116 * branch.isDefault; // false
117 * ```
118 *
119 * ### Promoting a Branch
120 * **Example:** Make a preview branch the default
121 * ```typescript
122 * const release = yield* Prisma.Branch("release", {
123 * project,
124 * gitName: "release/next",
125 * isDefault: true,
126 * });
127 *
128 * release.role; // still "preview"
129 * release.isDefault; // true
130 * ```
131 *
132 * @resource
133 * @product Project
134 */
135export const Branch = Resource<Branch>("Prisma.Branch");
136
137const createGitName = (id: string, gitName: string | undefined) =>
138 gitName === undefined ? createPhysicalName({ id }) : Effect.succeed(gitName);
139
140// Distilled emits the cursor-paginated list operations as plain ops, so
141// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
142const listBranches = (
143 projectId: string,
144 query?: { readonly gitName?: string },
145) =>
146 Effect.gen(function* () {
147 const branches: GetProjectBranchesResponse["data"][number][] = [];
148 let cursor: string | undefined;
149 while (true) {
150 const page = yield* getProjectBranches({
151 projectId,
152 ...(query?.gitName === undefined ? {} : { gitName: query.gitName }),
153 ...(cursor === undefined ? {} : { cursor }),
154 });
155 branches.push(...page.data);
156 const nextCursor = page.pagination.nextCursor;
157 if (!page.pagination.hasMore) break;
158 if (nextCursor === null) {
159 return yield* Effect.fail(
160 new PrismaPaginationError({
161 message:
162 "Invalid Prisma Management API pagination response from getProjectBranches: hasMore was true without a non-empty nextCursor",
163 }),
164 );
165 }
166 cursor = nextCursor;
167 }
168 return branches;
169 });
170
171const listProjects = () =>
172 Effect.gen(function* () {
173 const projects: GetProjectsResponse["data"][number][] = [];
174 let cursor: string | undefined;
175 while (true) {
176 const page = yield* getProjects(cursor === undefined ? {} : { cursor });
177 projects.push(...page.data);
178 const nextCursor = page.pagination.nextCursor;
179 if (!page.pagination.hasMore) break;
180 if (nextCursor === null) {
181 return yield* Effect.fail(
182 new PrismaPaginationError({
183 message:
184 "Invalid Prisma Management API pagination response from getProjects: hasMore was true without a non-empty nextCursor",
185 }),
186 );
187 }
188 cursor = nextCursor;
189 }
190 return projects;
191 });
192
193const findBranch = (projectId: string, gitName: string) =>
194 listBranches(projectId, { gitName }).pipe(
195 Effect.flatMap((branches) => {
196 const matches = branches.filter(
197 (b: ObservedBranch) => b.gitName === gitName,
198 );
199 return matches.length > 1
200 ? Effect.fail(
201 new Error(
202 `Prisma project '${projectId}' has multiple branches named '${gitName}'; refusing to select one arbitrarily.`,
203 ),
204 )
205 : Effect.succeed(matches[0]);
206 }),
207 );
208
209const attrsFrom = (
210 branch: ObservedBranch,
211 previousDefaultBranchId?: string,
212): Branch["Attributes"] => ({
213 branchId: branch.id,
214 gitName: branch.gitName,
215 projectId: branch.project.id,
216 isDefault: branch.isDefault,
217 ...(previousDefaultBranchId === undefined ? {} : { previousDefaultBranchId }),
218 role: branch.role,
219 createdAt: branch.createdAt,
220 updatedAt: branch.updatedAt,
221});
222
223const ensureBranchIdentity = (
224 branch: ObservedBranch,
225 expected: { projectId: string; gitName: string },
226) =>
227 branch.project.id === expected.projectId &&
228 branch.gitName === expected.gitName
229 ? Effect.void
230 : Effect.fail(
231 new Error(
232 `Prisma branch '${branch.id}' belongs to project '${branch.project.id}' with git name '${branch.gitName}', but this resource requires project '${expected.projectId}' and git name '${expected.gitName}'. Refusing to promote or delete a mismatched branch.`,
233 ),
234 );
235
236const ProviderLive = () =>
237 Provider.effect(
238 Branch,
239 Effect.gen(function* () {
240 return {
241 stables: ["branchId"],
242 list: Effect.fn(function* () {
243 const projects = yield* listProjects();
244 const branches = yield* Effect.forEach(
245 projects,
246 (project) =>
247 listBranches(project.id).pipe(
248 Effect.catchTag("NotFound", () => Effect.succeed([])),
249 ),
250 { concurrency: 8 },
251 );
252 // Current defaults and production-role branches are project-owned
253 // and undeletable through the Branch API. Project deletion removes
254 // them; omit them here so unsafe nuke only receives deletable rows.
255 return branches
256 .flat()
257 .filter(
258 (branch) => !branch.isDefault && branch.role !== "production",
259 )
260 .map((branch) => attrsFrom(branch));
261 }),
262 diff: Effect.fn(function* ({ id, olds, news, output }) {
263 if (!isInputObject(news)) return undefined;
264 if (isPrismaDevId(output?.branchId)) {
265 return { action: "update" } as const;
266 }
267 const oldProjectId =
268 output?.projectId ?? unresolvedProjectIdOf(olds.project);
269 const newProjectId = isResolved(news.project)
270 ? unresolvedProjectIdOf(news.project)
271 : undefined;
272 const newGitName = isResolved(news.gitName)
273 ? yield* createGitName(id, news.gitName)
274 : undefined;
275 if (
276 concreteIdsChanged(oldProjectId, newProjectId) ||
277 (newGitName !== undefined &&
278 newGitName !==
279 (output?.gitName ?? (yield* createGitName(id, olds.gitName))))
280 ) {
281 return { action: "replace" } as const;
282 }
283 if (!isResolved(news.isDefault)) return undefined;
284 // The Management API can promote a branch, atomically demoting the
285 // old default, but explicitly rejects demoting the current default.
286 // `false` therefore means "do not promote", not "demote".
287 if (news.isDefault === true && output?.isDefault !== true) {
288 return { action: "update" } as const;
289 }
290 return undefined;
291 }),
292 read: Effect.fn(function* ({ id, output, olds }) {
293 const branchId = isPrismaDevId(output?.branchId)
294 ? undefined
295 : output?.branchId;
296 const branch = branchId
297 ? yield* getBranch({ branchId }).pipe(
298 Effect.map((response) => response.data),
299 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
300 )
301 : yield* Effect.gen(function* () {
302 const projectId = unresolvedProjectIdOf(olds.project);
303 return projectId
304 ? yield* findBranch(
305 projectId,
306 yield* createGitName(id, olds.gitName),
307 )
308 : undefined;
309 });
310 if (!branch) return undefined;
311 const attrs = attrsFrom(branch, output?.previousDefaultBranchId);
312 return branchId === undefined ? Unowned(attrs) : attrs;
313 }),
314 reconcile: Effect.fn(function* ({ id, news, output }) {
315 const projectId = yield* resolveProjectId(news.project);
316 const gitName = yield* createGitName(id, news.gitName);
317 let previousDefaultBranchId = output?.previousDefaultBranchId;
318 const branchId = isPrismaDevId(output?.branchId)
319 ? undefined
320 : output?.branchId;
321 let branch = branchId
322 ? yield* getBranch({ branchId }).pipe(
323 Effect.map((response) => response.data),
324 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
325 )
326 : undefined;
327 if (!branch) {
328 const liveBranches = yield* listBranches(projectId);
329 const defaults = liveBranches.filter(
330 (candidate) => candidate.isDefault,
331 );
332 if (defaults.length !== 1) {
333 return yield* Effect.fail(
334 new Error(
335 defaults.length === 0
336 ? `Prisma project '${projectId}' has no default branch. Refusing to create a standalone Branch because the Management API would make it the undeletable production branch; create or repair the owning Prisma.Project first.`
337 : `Prisma project '${projectId}' has ${defaults.length} default branches. Refusing to create a Branch until the project invariant is repaired.`,
338 ),
339 );
340 }
341 previousDefaultBranchId = defaults[0]!.id;
342 branch = yield* createProjectBranch({
343 projectId,
344 gitName,
345 ...(news.isDefault === undefined
346 ? {}
347 : { isDefault: news.isDefault }),
348 }).pipe(
349 // A replayed create would make a second branch; the retry
350 // policy cannot see the request, so opt out explicitly.
351 Retry.none,
352 Effect.map((response) => response.data),
353 Effect.catchTag("Conflict", () =>
354 Effect.fail(
355 new Error(
356 `Prisma branch '${gitName}' appeared after the adoption check. Refusing to take it over; rerun with adoption enabled if it is the intended branch.`,
357 ),
358 ),
359 ),
360 );
361 }
362 yield* ensureBranchIdentity(branch, {
363 projectId,
364 gitName,
365 });
366 if (news.isDefault === true && !branch.isDefault) {
367 const liveBranches = yield* listBranches(projectId);
368 const defaults = liveBranches.filter(
369 (candidate) => candidate.isDefault,
370 );
371 if (defaults.length !== 1 || defaults[0]!.id === branch.id) {
372 return yield* Effect.fail(
373 new Error(
374 `Prisma project '${projectId}' does not have exactly one different default branch to restore later. Refusing to promote '${branch.gitName}'.`,
375 ),
376 );
377 }
378 previousDefaultBranchId = defaults[0]!.id;
379 branch = (yield* updateBranch({
380 branchId: branch.id,
381 isDefault: true,
382 })).data;
383 yield* ensureBranchIdentity(branch, {
384 projectId,
385 gitName,
386 });
387 if (!branch.isDefault) {
388 return yield* Effect.fail(
389 new Error(
390 `Prisma branch '${branch.id}' promotion did not converge to the default branch.`,
391 ),
392 );
393 }
394 }
395 return attrsFrom(branch, previousDefaultBranchId);
396 }),
397 delete: Effect.fn(function* ({ output }) {
398 if (isPrismaDevId(output.branchId)) return;
399 const branch = yield* getBranch({
400 branchId: output.branchId,
401 }).pipe(
402 Effect.map((response) => response.data),
403 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
404 );
405 if (!branch) return;
406 yield* ensureBranchIdentity(branch, {
407 projectId: output.projectId,
408 gitName: output.gitName,
409 });
410 if (branch.role === "production") {
411 return yield* Effect.fail(
412 new Error(
413 `Cannot delete Prisma branch '${branch.gitName ?? output.branchId}' directly because its production role is immutable. Delete the owning Prisma.Project.`,
414 ),
415 );
416 }
417 if (branch.isDefault) {
418 const previousDefaultBranchId = output.previousDefaultBranchId;
419 if (
420 previousDefaultBranchId === undefined ||
421 previousDefaultBranchId === branch.id
422 ) {
423 return yield* Effect.fail(
424 new Error(
425 `Cannot safely delete default Prisma branch '${branch.gitName}' because state does not identify the branch it displaced. Promote another branch explicitly before deleting this resource.`,
426 ),
427 );
428 }
429 const previous = yield* getBranch({
430 branchId: previousDefaultBranchId,
431 }).pipe(
432 Effect.map((response) => response.data),
433 Effect.catchTag("NotFound", () =>
434 Effect.fail(
435 new Error(
436 `Cannot restore previous default Prisma branch '${previousDefaultBranchId}' because it no longer exists. Promote another branch explicitly before deleting '${branch.gitName}'.`,
437 ),
438 ),
439 ),
440 );
441 if (
442 previous.project.id !== output.projectId ||
443 previous.id === branch.id
444 ) {
445 return yield* Effect.fail(
446 new Error(
447 `Previous default Prisma branch '${previous.id}' does not belong to project '${output.projectId}'. Refusing an unsafe promotion.`,
448 ),
449 );
450 }
451 const restored = previous.isDefault
452 ? previous
453 : (yield* updateBranch({
454 branchId: previous.id,
455 isDefault: true,
456 })).data;
457 if (
458 restored.id !== previous.id ||
459 restored.project.id !== output.projectId ||
460 !restored.isDefault
461 ) {
462 return yield* Effect.fail(
463 new Error(
464 `Prisma did not confirm restoration of previous default branch '${previous.id}'. Refusing to delete '${branch.gitName}'.`,
465 ),
466 );
467 }
468 const demoted = yield* getBranch({
469 branchId: branch.id,
470 }).pipe(
471 Effect.map((response) => response.data),
472 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
473 );
474 if (!demoted) return;
475 yield* ensureBranchIdentity(demoted, {
476 projectId: output.projectId,
477 gitName: output.gitName,
478 });
479 if (demoted.isDefault) {
480 return yield* Effect.fail(
481 new Error(
482 `Prisma branch '${branch.gitName}' remained default after restoring '${previous.gitName}'. Refusing to delete it.`,
483 ),
484 );
485 }
486 }
487 yield* deleteBranch({
488 branchId: output.branchId,
489 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
490 }),
491 };
492 }),
493 );
494
495const ProviderLocal = () =>
496 devProvider(Branch, ["branchId"], ({ id, news }) => ({
497 branchId: devId("branch", id),
498 gitName: news.gitName ?? id,
499 projectId: attrOrString(news.project, "projectId"),
500 isDefault: news.isDefault ?? false,
501 role: news.isDefault ? "production" : "preview",
502 createdAt: DEV_TIMESTAMP,
503 updatedAt: DEV_TIMESTAMP,
504 }));
505
506export const BranchProvider = () =>
507 ProviderLayer.dual(Branch, {
508 local: () => ProviderLocal(),
509 live: () => ProviderLive(),
510 });