1import {
2 createProjectBranch,
3 createProjectEndpoint,
4 deleteProjectEndpoint,
5 getProject,
6 listProjectBranchEndpoints,
7 updateProjectEndpoint,
8 deleteProjectBranch,
9 getConnectionURI,
10 getProjectBranch,
11 listProjectBranchDatabases,
12 listProjectBranches,
13 type ListProjectBranchesResponse,
14 listProjects,
15 type ListProjectsResponse,
16 updateProjectBranch,
17} from "@distilled.cloud/neon";
18import * as Data from "effect/Data";
19import * as Effect from "effect/Effect";
20import * as Redacted from "effect/Redacted";
21import * as Schedule from "effect/Schedule";
22import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts";
23import { isResolved } from "../Diff.ts";
24import { createPhysicalName } from "../PhysicalName.ts";
25import * as Provider from "../Provider.ts";
26import { Resource } from "../Resource.ts";
27import {
28 diffMigrations,
29 migrationsAttrs,
30 migrationsInputOf,
31 stampedOf,
32 type MigrationsInput,
33} from "../SQL/Migrations/index.ts";
34import { hashImports, readSqlFile } from "../SQL/SqlFile.ts";
35import { recordsEqual } from "../Util/equal.ts";
36import { runPgMigrations, runSql } from "./Migrations.ts";
37import { parsePostgresOrigin, type PostgresOrigin } from "./PostgresOrigin.ts";
38import { DeletionPending, type Project, waitForOperations } from "./Project.ts";
39import type { Providers } from "./Providers.ts";
40
41export type BranchSource = Project | { projectId: string };
42
43export type ParentBranchSource =
44 | Branch
45 | { branchId: string }
46 | { name: string };
47
48export type BranchEndpointConfig = {
49 /** Endpoint access mode. A branch has exactly one read-write endpoint. */
50 type: "read_only" | "read_write";
51 /** Minimum compute units. Removing this setting restores the project default. */
52 autoscalingLimitMinCu?: number;
53 /** Maximum compute units. Removing this setting restores the project default. */
54 autoscalingLimitMaxCu?: number;
55 /** Idle timeout in seconds; 0 selects the plan default and -1 disables suspension. */
56 suspendTimeoutSeconds?: number;
57};
58
59export type BranchProps = {
60 /**
61 * The Neon project (or `{ projectId }`) to create the branch in.
62 */
63 project: BranchSource;
64 /**
65 * Branch name. If omitted, a unique name is generated from
66 * `${app}-${stage}-${id}`.
67 */
68 name?: string;
69 /**
70 * The parent branch to fork from. Accepts a `Branch`, a
71 * `{ branchId }`, or `{ name }` to look up by name. Defaults to the
72 * project's default branch.
73 */
74 parentBranch?: ParentBranchSource;
75 /**
76 * A Log Sequence Number on the parent branch. The new branch is created
77 * with parent data as of this LSN.
78 */
79 parentLsn?: string;
80 /**
81 * An ISO-8601 timestamp identifying a point in time on the parent branch
82 * to fork from.
83 */
84 parentTimestamp?: string;
85 /**
86 * Whether the branch is protected from deletion / mutation.
87 *
88 * @default false
89 */
90 protected?: boolean;
91 /**
92 * Initialization source.
93 *
94 * - `parent-data` (default) — copy schema and data from the parent.
95 * - `schema-only` — copy only the schema.
96 */
97 initSource?: "schema-only" | "parent-data";
98 /**
99 * RFC-3339 timestamp at which Neon should auto-delete the branch.
100 * Useful for ephemeral preview branches.
101 */
102 expiresAt?: string;
103 /**
104 * Compute endpoints owned by this branch. Exactly one `read_write` endpoint
105 * is required for the connection outputs. Settings update in place; removing
106 * a setting restores the project default and removing an endpoint deletes it.
107 * Do not independently manage endpoints on a branch managed by this resource.
108 *
109 * @default [{ type: "read_write" }]
110 */
111 endpoints?: BranchEndpointConfig[];
112 /**
113 * SQL migrations to apply against the branch. Accepts a directory path, a
114 * `Drizzle.Schema` resource, or `{ dir, table? }`.
115 *
116 * Bookkeeping always lives in Alchemy's `__alchemy_migrations` table. A
117 * database previously migrated by drizzle-kit or Prisma is adopted by a
118 * one-way conversion on first deploy: the old tool's applied history is
119 * copied into Alchemy's table and the old table is left frozen. No
120 * baselining required.
121 */
122 migrations?: MigrationsInput;
123 /**
124 * Paths to additional `.sql` files to apply after migrations.
125 */
126 importFiles?: string[];
127};
128
129export type Branch = Resource<
130 "Neon.Branch",
131 BranchProps,
132 {
133 /** Neon branch identifier. */
134 branchId: string;
135 /** Observed branch name. */
136 branchName: string;
137 /** Owning project identifier. */
138 projectId: string;
139 /** Parent branch identifier, when present. */
140 parentBranchId: string | undefined;
141 /** Parent log sequence number used for the fork. */
142 parentLsn: string | undefined;
143 /** Parent timestamp used for the fork. */
144 parentTimestamp: string | undefined;
145 /** Whether the fork copied data or only schema. */
146 initSource: "schema-only" | "parent-data" | undefined;
147 /** Whether deletion and mutation protection is enabled. */
148 protected: boolean;
149 /** Whether this is the project's current default branch. */
150 default: boolean;
151 /** Automatic expiration timestamp, when configured. */
152 expiresAt: string | undefined;
153 /** Database selected for connection outputs and migrations. */
154 databaseName: string;
155 /** Observed owner of the selected database. */
156 roleName: string;
157 /** Postgres connection URI for the branch's primary database. */
158 connectionUri: string;
159 /** Pooled connection URI (uses pgbouncer). */
160 pooledConnectionUri: string;
161 /**
162 * Parsed connection components ready to feed into a Postgres origin
163 * — e.g. `Cloudflare.Hyperdrive`'s `origin` prop. Points at the
164 * direct (non-pooled) endpoint, which is the recommended target
165 * when fronting Neon with another pooler like Hyperdrive.
166 */
167 origin: PostgresOrigin;
168 /**
169 * Parsed pooled connection components. Useful as a Hyperdrive `dev`
170 * origin when local workers bypass Hyperdrive and connect directly.
171 */
172 pooledOrigin: PostgresOrigin;
173 /** Directory of the applied SQL migrations. */
174 migrationsDir: string | undefined;
175 /** Migration bookkeeping table. */
176 migrationsTable: string | undefined;
177 /** Applied migration content hashes. */
178 migrationsHashes: Record<string, string>;
179 /** Applied SQL import content hashes. */
180 importHashes: Record<string, string>;
181 },
182 never,
183 Providers
184>;
185
186/**
187 * A branch of a Neon project.
188 *
189 * Branches are first-class, copy-on-write copies of a parent branch — they
190 * share storage with the parent until the new branch starts diverging.
191 * ### Branching from a project's default branch
192 * **Example:** Basic branch
193 * ```typescript
194 * const project = yield* Neon.Project("my-project");
195 * const dev = yield* Neon.Branch("dev-branch", { project });
196 * ```
197 *
198 * ### Branching from another branch
199 * **Example:** Branch off another branch
200 * ```typescript
201 * const dev = yield* Neon.Branch("dev", { project });
202 * const featureBranch = yield* Neon.Branch("feature", {
203 * project,
204 * parentBranch: dev,
205 * });
206 * ```
207 *
208 * ### Point-in-time branches
209 * **Example:** Branch from a parent at a specific LSN
210 * ```typescript
211 * const branch = yield* Neon.Branch("at-lsn", {
212 * project,
213 * parentLsn: "0/3FA01B0",
214 * });
215 * ```
216 *
217 * ### Migrations on a branch
218 * **Example:** Apply migrations on the branch only
219 * ```typescript
220 * const featureBranch = yield* Neon.Branch("feature", {
221 * project,
222 * migrations: "./migrations",
223 * });
224 * ```
225 *
226 * @see https://neon.tech/docs/manage/branches/
227 *
228 * @resource
229 * @product Branch
230 */
231export const Branch = Resource<Branch>("Neon.Branch");
232
233export const BranchProvider = () =>
234 Provider.succeed(Branch, {
235 stables: ["branchId", "projectId"],
236 diff: Effect.fn(function* ({ id, olds, news, output }) {
237 // Normally we short-circuit on `isResolved(news)` at the beginning.
238 // However, this wouldn't detect an upstream project change, causing an update when what we really want is a replace.
239 // So, we check the project first before short-circuiting. `projectId` is a stable attribute of `Project`, so the
240 // planning engine resolves `news.project` to a plain object carrying that stable id (even when the project is being
241 // updated in place). An unchanged project therefore resolves to the same string; a changed/replaced project resolves
242 // to either a different string or an unresolved output, so `oldProjectId !== newProjectId` evaluates correctly.
243 // An Output-valued `project` doesn't survive a `creating`-state
244 // round-trip (it deserializes as `undefined`) — when the old project is
245 // unknown, fall through to the create/update recovery path rather than
246 // force a replacement.
247 const oldProjectId =
248 output?.projectId ??
249 (olds.project !== undefined
250 ? maybeResolveProjectId(olds.project as BranchSource)
251 : undefined);
252 const newProjectId =
253 "project" in news
254 ? maybeResolveProjectId(news.project as BranchSource)
255 : undefined;
256 if (oldProjectId !== undefined && oldProjectId !== newProjectId) {
257 return { action: "replace" } as const;
258 }
259 if (!isResolved(news)) return undefined;
260 const replacement = {
261 action: "replace",
262 deleteFirst:
263 news.name !== undefined &&
264 news.name === (output?.branchName ?? olds.name) &&
265 oldProjectId === newProjectId,
266 } as const;
267 if (
268 olds.parentLsn !== news.parentLsn ||
269 olds.parentTimestamp !== news.parentTimestamp ||
270 (olds.initSource ?? "parent-data") !==
271 (news.initSource ?? "parent-data")
272 ) {
273 return replacement;
274 }
275 if (output && news.parentBranch) {
276 const parent = yield* resolveParentBranchId(
277 news.parentBranch as ParentBranchSource,
278 output.projectId,
279 );
280 // Schema-only branches are roots; the source is a creation input,
281 // not an observed parent relationship.
282 const previousParent =
283 news.initSource === "schema-only"
284 ? yield* resolveParentBranchId(
285 olds.parentBranch as ParentBranchSource | undefined,
286 output.projectId,
287 )
288 : output.parentBranchId;
289 if (parent !== previousParent) return replacement;
290 } else if (output && olds.parentBranch && !news.parentBranch) {
291 return replacement;
292 }
293 const oldName =
294 output?.branchName ?? (yield* createBranchName(id, olds.name));
295 // Auto-generated names are engine-owned: the deployed name stays
296 // authoritative even if the generator would name this id differently
297 // today. Only an explicit user-provided name can force a rename.
298 const newName = news.name ?? oldName;
299 if (
300 newName !== oldName ||
301 (news.protected ?? false) !== (output?.protected ?? false) ||
302 (news.expiresAt ?? undefined) !== (output?.expiresAt ?? undefined)
303 ) {
304 return { action: "update" } as const;
305 }
306 if (yield* diffMigrations({ news, output })) {
307 return { action: "update" } as const;
308 }
309 if (news.importFiles?.length) {
310 const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
311 if (!recordsEqual(newHashes, output?.importHashes ?? {})) {
312 return { action: "update" } as const;
313 }
314 }
315 return undefined;
316 }),
317 read: Effect.fn(function* ({ id, output, olds }) {
318 if (output?.branchId) {
319 return yield* getProjectBranch({
320 project_id: output.projectId,
321 branch_id: output.branchId,
322 }).pipe(
323 Effect.flatMap(({ branch }) =>
324 hydrateBranch(output.projectId, branch, output),
325 ),
326 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
327 );
328 }
329 if (!olds?.project) return undefined;
330 const projectId = maybeResolveProjectId(olds.project as BranchSource);
331 if (projectId === undefined) {
332 // The project reference survived as an object but its Output-valued
333 // `projectId` did not — there is nothing to look the branch up in.
334 return undefined;
335 }
336 const name = yield* createBranchName(id, olds.name);
337 // The whole project may already be gone (out-of-band deletion); the
338 // branch is gone with it, so recovery observes an absent branch.
339 const matches = yield* findBranchByName(projectId, name).pipe(
340 Effect.catchTag("NotFound", () => Effect.succeed([])),
341 );
342 if (matches.length > 1)
343 return yield* new BranchStateError({ reason: "Ambiguous branch name" });
344 const match = matches[0];
345 if (!match) return undefined;
346 const attrs = yield* hydrateBranch(projectId, match);
347 return attrs && Unowned(attrs);
348 }),
349 reconcile: Effect.fn(function* ({ id, news, output }) {
350 const projectId = yield* resolveProjectId(news.project as BranchSource);
351 const newName =
352 news.name ??
353 output?.branchName ??
354 (yield* createBranchName(id, undefined));
355 const endpoints = news.endpoints ?? [{ type: "read_write" as const }];
356 if (
357 endpoints.filter((endpoint) => endpoint.type === "read_write")
358 .length !== 1
359 ) {
360 return yield* new BranchStateError({
361 reason:
362 "Branch requires exactly one read_write endpoint for its connection outputs",
363 });
364 }
365 // Approval of a cached branch never authorizes a different same-name ID.
366 const authorize = (
367 branch: ListProjectBranchesResponse["branches"][number],
368 ): Effect.Effect<
369 ListProjectBranchesResponse["branches"][number],
370 OwnedBySomeoneElse
371 > =>
372 projectId === output?.projectId && branch.id === output.branchId
373 ? Effect.succeed(branch)
374 : Effect.fail(
375 new OwnedBySomeoneElse({
376 message: `Neon branch "${newName}" requires explicit adoption`,
377 resourceType: "Neon.Branch",
378 logicalId: id,
379 physicalName: branch.id,
380 }),
381 );
382 let observed =
383 output?.branchId && output.projectId === projectId
384 ? yield* getProjectBranch({
385 project_id: projectId,
386 branch_id: output.branchId,
387 }).pipe(
388 Effect.map(({ branch }) => branch),
389 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
390 )
391 : undefined;
392 if (!observed) {
393 const matches = yield* findBranchByName(projectId, newName);
394 if (matches.length > 1)
395 return yield* new BranchStateError({
396 reason: "Ambiguous branch name",
397 });
398 observed = matches[0] ? yield* authorize(matches[0]) : undefined;
399 }
400 if (!observed) {
401 const parentBranchId = yield* resolveParentBranchId(
402 news.parentBranch as ParentBranchSource | undefined,
403 projectId,
404 );
405 const created = yield* createProjectBranch({
406 project_id: projectId,
407 branch: {
408 name: newName,
409 parent_id: parentBranchId,
410 parent_lsn: news.parentLsn,
411 parent_timestamp: news.parentTimestamp,
412 init_source: news.initSource,
413 protected: news.protected,
414 expires_at: news.expiresAt,
415 },
416 endpoints: buildEndpoints(endpoints),
417 }).pipe(
418 Effect.catchTag("Conflict", (error) =>
419 findBranchByName(projectId, newName).pipe(
420 Effect.flatMap(
421 Effect.fn(function* (matches) {
422 if (matches.length !== 1) return yield* Effect.fail(error);
423 const branch = yield* authorize(matches[0]!);
424 return { branch, operations: [] };
425 }),
426 ),
427 ),
428 ),
429 );
430 yield* waitForOperations(created.operations);
431 observed = created.branch;
432 }
433 if (
434 observed.name !== newName ||
435 observed.protected !== (news.protected ?? false) ||
436 observed.expires_at !== news.expiresAt
437 ) {
438 const updated = yield* updateProjectBranch({
439 project_id: projectId,
440 branch_id: observed.id,
441 branch: {
442 name: observed.name !== newName ? newName : undefined,
443 protected:
444 observed.protected !== (news.protected ?? false)
445 ? (news.protected ?? false)
446 : undefined,
447 expires_at:
448 observed.expires_at !== news.expiresAt
449 ? (news.expiresAt ?? null)
450 : undefined,
451 },
452 });
453 yield* waitForOperations(updated.operations);
454 }
455 yield* syncEndpoints(projectId, observed.id, endpoints);
456 const current = yield* getProjectBranch({
457 project_id: projectId,
458 branch_id: observed.id,
459 });
460 const branchInfo = yield* hydrateBranch(
461 projectId,
462 current.branch,
463 output?.branchId === observed.id ? output : undefined,
464 );
465 if (!branchInfo)
466 return yield* new BranchStateError({
467 reason: "Branch has no database or connection endpoint",
468 });
469 const previous =
470 branchInfo.branchId === output?.branchId ? output : undefined;
471
472 const connectionUri = Redacted.make(branchInfo.connectionUri);
473 const migrationsInput = migrationsInputOf(news);
474 const migrations = migrationsInput
475 ? yield* runPgMigrations({
476 connectionUri,
477 input: migrationsInput,
478 stamped: stampedOf(previous),
479 })
480 : undefined;
481 const importHashes = news.importFiles?.length
482 ? yield* runImports(
483 connectionUri,
484 news.importFiles,
485 yield* rootDir,
486 previous?.importHashes ?? {},
487 )
488 : {};
489
490 return {
491 ...branchInfo,
492 ...migrationsAttrs({
493 input: migrationsInput,
494 run: migrations,
495 output: previous,
496 }),
497 importHashes,
498 };
499 }),
500 delete: Effect.fn(function* ({ output }) {
501 yield* Effect.gen(function* () {
502 const { branch } = yield* getProjectBranch({
503 project_id: output.projectId,
504 branch_id: output.branchId,
505 });
506 if (branch.protected) {
507 const updated = yield* updateProjectBranch({
508 project_id: output.projectId,
509 branch_id: output.branchId,
510 branch: { protected: false },
511 });
512 yield* waitForOperations(updated.operations);
513 }
514 const deleted = yield* deleteProjectBranch({
515 project_id: output.projectId,
516 branch_id: output.branchId,
517 });
518 yield* waitForOperations(deleted.operations);
519 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
520 yield* getProjectBranch({
521 project_id: output.projectId,
522 branch_id: output.branchId,
523 }).pipe(
524 Effect.flatMap(() =>
525 Effect.fail(new DeletionPending({ resourceId: output.branchId })),
526 ),
527 Effect.catchTag("NotFound", () => Effect.void),
528 Effect.retry({
529 while: (error) => error._tag === "NeonDeletionPending",
530 schedule: Schedule.spaced("2 seconds"),
531 times: 8,
532 }),
533 Effect.timeout("20 seconds"),
534 );
535 }),
536 // Parent fan-out: branches are scoped to a project, and there is no
537 // account-wide branch enumeration API. Enumerate every project, then
538 // list+hydrate the branches of each (bounded concurrency), producing
539 // the exact `read` Attributes shape for each branch.
540 list: Effect.fn(function* () {
541 const projects = yield* listAllProjects;
542 const perProject = yield* Effect.forEach(
543 projects,
544 (project) =>
545 Effect.gen(function* () {
546 const branches = yield* listAllBranches(project.id);
547 return yield* Effect.forEach(
548 branches,
549 (branch) => hydrateBranch(project.id, branch),
550 { concurrency: 10 },
551 );
552 }).pipe(
553 // The project may be deleted between enumeration and listing.
554 Effect.catchTag("NotFound", () => Effect.succeed([])),
555 ),
556 { concurrency: 10 },
557 );
558 return perProject
559 .flat()
560 .filter((row): row is Branch["Attributes"] => row !== undefined);
561 }),
562 });
563
564const listAllProjects = Effect.gen(function* () {
565 const projects: ListProjectsResponse["projects"][number][] = [];
566 let cursor: string | undefined;
567 while (true) {
568 const page = yield* listProjects(cursor !== undefined ? { cursor } : {});
569 projects.push(...page.projects);
570 const nextCursor = page.pagination?.cursor;
571 // Neon returns a `pagination.cursor` on every response (the `created_at`
572 // of the last row), not a "has next page" flag — stop once a page comes
573 // back empty or the cursor stops advancing to avoid an infinite loop.
574 if (
575 page.projects.length === 0 ||
576 nextCursor === undefined ||
577 nextCursor === cursor
578 ) {
579 break;
580 }
581 cursor = nextCursor;
582 }
583 return projects;
584});
585
586const listAllBranches = (projectId: string) =>
587 Effect.gen(function* () {
588 const branches: ListProjectBranchesResponse["branches"][number][] = [];
589 let cursor: string | undefined;
590 do {
591 const page = yield* listProjectBranches({
592 project_id: projectId,
593 ...(cursor !== undefined ? { cursor } : {}),
594 });
595 branches.push(...page.branches);
596 cursor = page.pagination?.next;
597 } while (cursor);
598 return branches;
599 });
600
601const hydrateBranch = (
602 projectId: string,
603 branch: ListProjectBranchesResponse["branches"][number],
604 previous?: Branch["Attributes"],
605) =>
606 Effect.gen(function* () {
607 const dbs = yield* listProjectBranchDatabases({
608 project_id: projectId,
609 branch_id: branch.id,
610 });
611 const db =
612 dbs.databases.find((db) => db.name === previous?.databaseName) ??
613 dbs.databases[0];
614 if (!db) return undefined;
615 const conn = yield* fetchConnection(
616 projectId,
617 branch.id,
618 db.name,
619 db.owner_name,
620 );
621 const attributes: Branch["Attributes"] = {
622 branchId: branch.id,
623 branchName: branch.name,
624 projectId,
625 parentBranchId: branch.parent_id,
626 parentLsn: branch.parent_lsn,
627 parentTimestamp: branch.parent_timestamp,
628 initSource:
629 branch.init_source === "parent-schema" ||
630 branch.init_source === "schema-only"
631 ? "schema-only"
632 : branch.init_source === "parent-data"
633 ? "parent-data"
634 : undefined,
635 protected: branch.protected,
636 default: branch.default,
637 expiresAt: branch.expires_at,
638 databaseName: db.name,
639 roleName: db.owner_name,
640 connectionUri: conn.uri,
641 pooledConnectionUri: conn.pooled,
642 origin: parsePostgresOrigin(conn.uri),
643 pooledOrigin: parsePostgresOrigin(conn.pooled),
644 migrationsDir: previous?.migrationsDir,
645 migrationsTable: previous?.migrationsTable,
646 migrationsHashes: previous?.migrationsHashes ?? {},
647 importHashes: previous?.importHashes ?? {},
648 };
649 return attributes;
650 }).pipe(
651 // A branch/database/endpoint can disappear mid-enumeration — skip it.
652 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
653 );
654
655const rootDir = Effect.sync(() => process.cwd());
656const createBranchName = (id: string, name: string | undefined) =>
657 Effect.gen(function* () {
658 return name ?? (yield* createPhysicalName({ id }));
659 });
660
661const findBranchByName = (projectId: string, name: string) =>
662 Effect.gen(function* () {
663 const matches: ListProjectBranchesResponse["branches"][number][] = [];
664 let cursor: string | undefined;
665 do {
666 const page = yield* listProjectBranches({
667 project_id: projectId,
668 search: name,
669 ...(cursor !== undefined ? { cursor } : {}),
670 });
671 for (const b of page.branches) {
672 if (b.name === name) matches.push(b);
673 }
674 cursor = page.pagination?.next;
675 } while (cursor);
676 return matches;
677 });
678
679const maybeResolveProjectId = (source: BranchSource): string | undefined => {
680 if (source && "projectId" in source && typeof source.projectId === "string") {
681 return source.projectId;
682 }
683 return undefined;
684};
685
686const resolveProjectId = (source: BranchSource) => {
687 const projectId = maybeResolveProjectId(source);
688 return projectId
689 ? Effect.succeed(projectId)
690 : Effect.fail(
691 new BranchStateError({
692 reason:
693 "Invalid Neon project source: must be a Project or { projectId }",
694 }),
695 );
696};
697
698const resolveParentBranchId = (
699 source: ParentBranchSource | undefined,
700 projectId: string,
701) =>
702 Effect.gen(function* () {
703 if (!source) return undefined as string | undefined;
704 if ("branchId" in source && typeof source.branchId === "string") {
705 if (
706 "projectId" in source &&
707 typeof source.projectId === "string" &&
708 source.projectId !== projectId
709 ) {
710 return yield* new BranchStateError({
711 reason: "Parent branch belongs to another project",
712 });
713 }
714 return source.branchId;
715 }
716 if ("name" in source && source.name) {
717 const matches = yield* findBranchByName(projectId, source.name);
718 if (matches.length === 0) {
719 return yield* new BranchStateError({
720 reason: `Parent branch "${source.name}" not found in project ${projectId}`,
721 });
722 }
723 if (matches.length > 1) {
724 return yield* new BranchStateError({
725 reason: `Multiple branches with name "${source.name}" in project ${projectId}`,
726 });
727 }
728 return matches[0]!.id;
729 }
730 return undefined as string | undefined;
731 });
732
733export class BranchStateError extends Data.TaggedError("NeonBranchStateError")<{
734 reason: string;
735}> {}
736
737const syncEndpoints = Effect.fn(function* (
738 projectId: string,
739 branchId: string,
740 desired: BranchEndpointConfig[],
741) {
742 const { project } = yield* getProject({ project_id: projectId });
743 const defaults = project.default_endpoint_settings ?? {};
744 const { endpoints } = yield* listProjectBranchEndpoints({
745 project_id: projectId,
746 branch_id: branchId,
747 });
748 const remaining = [...endpoints].sort((a, b) => a.id.localeCompare(b.id));
749 for (const config of desired) {
750 const index = remaining.findIndex(
751 (endpoint) => endpoint.type === config.type,
752 );
753 let observed = index < 0 ? undefined : remaining.splice(index, 1)[0];
754 const settings = {
755 autoscaling_limit_min_cu:
756 config.autoscalingLimitMinCu ??
757 defaults.autoscaling_limit_min_cu ??
758 0.25,
759 autoscaling_limit_max_cu:
760 config.autoscalingLimitMaxCu ?? defaults.autoscaling_limit_max_cu ?? 2,
761 suspend_timeout_seconds:
762 config.suspendTimeoutSeconds ?? defaults.suspend_timeout_seconds ?? 0,
763 };
764 if (!observed) {
765 const created = yield* createProjectEndpoint({
766 project_id: projectId,
767 endpoint: { branch_id: branchId, type: config.type, ...settings },
768 }).pipe(
769 Effect.catchTag("Conflict", (error) =>
770 listProjectBranchEndpoints({
771 project_id: projectId,
772 branch_id: branchId,
773 }).pipe(
774 Effect.flatMap(({ endpoints }) => {
775 const matches = endpoints.filter(
776 (endpoint) => endpoint.type === "read_write",
777 );
778 return config.type === "read_write" && matches.length === 1
779 ? Effect.succeed({ endpoint: matches[0]!, operations: [] })
780 : Effect.fail(error);
781 }),
782 ),
783 ),
784 );
785 yield* waitForOperations(created.operations);
786 observed = created.endpoint;
787 }
788 if (
789 observed.autoscaling_limit_min_cu !== settings.autoscaling_limit_min_cu ||
790 observed.autoscaling_limit_max_cu !== settings.autoscaling_limit_max_cu ||
791 observed.suspend_timeout_seconds !== settings.suspend_timeout_seconds
792 ) {
793 const updated = yield* updateProjectEndpoint({
794 project_id: projectId,
795 endpoint_id: observed.id,
796 endpoint: settings,
797 });
798 yield* waitForOperations(updated.operations);
799 }
800 }
801 for (const endpoint of remaining) {
802 yield* deleteProjectEndpoint({
803 project_id: projectId,
804 endpoint_id: endpoint.id,
805 }).pipe(
806 Effect.flatMap(({ operations }) => waitForOperations(operations)),
807 Effect.catchTag("NotFound", () => Effect.void),
808 );
809 }
810});
811
812const buildEndpoints = (endpoints: BranchEndpointConfig[] | undefined) => {
813 const list = endpoints ?? [{ type: "read_write" as const }];
814 return list.map((e) => ({
815 type: e.type,
816 autoscaling_limit_min_cu: e.autoscalingLimitMinCu,
817 autoscaling_limit_max_cu: e.autoscalingLimitMaxCu,
818 suspend_timeout_seconds: e.suspendTimeoutSeconds,
819 }));
820};
821
822const fetchConnection = (
823 projectId: string,
824 branchId: string,
825 databaseName: string,
826 roleName: string,
827) =>
828 Effect.gen(function* () {
829 const direct = yield* getConnectionURI({
830 project_id: projectId,
831 branch_id: branchId,
832 database_name: databaseName,
833 role_name: roleName,
834 pooled: false,
835 });
836 const pooled = yield* getConnectionURI({
837 project_id: projectId,
838 branch_id: branchId,
839 database_name: databaseName,
840 role_name: roleName,
841 pooled: true,
842 });
843 return { uri: direct.uri, pooled: pooled.uri };
844 });
845
846const runImports = (
847 connectionUri: Redacted.Redacted<string>,
848 importFiles: ReadonlyArray<string>,
849 rootDir: string,
850 previous: Record<string, string>,
851) =>
852 Effect.gen(function* () {
853 const hashes: Record<string, string> = { ...previous };
854 for (const filePath of importFiles) {
855 const file = yield* readSqlFile(rootDir, filePath);
856 if (previous[filePath] === file.hash) {
857 hashes[filePath] = file.hash;
858 continue;
859 }
860 yield* runSql(connectionUri, file.sql);
861 hashes[filePath] = file.hash;
862 }
863 const tracked = new Set(importFiles);
864 for (const key of Object.keys(hashes)) {
865 if (!tracked.has(key)) delete hashes[key];
866 }
867 return hashes;
868 });