1import * as planetscale from "@distilled.cloud/planetscale";
2import { Credentials } from "@distilled.cloud/planetscale/Credentials";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../AdoptPolicy.ts";
7import { havePropsChanged, isResolved } from "../Diff.ts";
8import { createPhysicalName } from "../PhysicalName.ts";
9import * as Provider from "../Provider.ts";
10import type { ResourceClass, ResourceLike } from "../Resource.ts";
11import { hashImports, hashMigrations } from "../SQL/SqlFile.ts";
12import { recordsEqual } from "../Util/equal.ts";
13import { ensureMySQLProductionBranchClusterSize } from "./MySQL/MySQLClusterSize.ts";
14import {
15 ensurePostgresProductionBranchClusterSize,
16 toPostgresClusterSku,
17 waitForPendingPostgresChanges,
18} from "./Postgres/PostgresClusterSize.ts";
19import {
20 diffMigrations,
21 migrationsAttrs,
22 migrationsInputOf,
23 stampedOf,
24 type MigrationRun,
25 type MigrationsInput,
26 type NormalizedMigrationsInput,
27 type StampedMigrationsState,
28} from "../SQL/Migrations/index.ts";
29import {
30 PlanetscaleConflict,
31 isKnownError,
32 waitForBranchReady,
33} from "./Util.ts";
34
35/**
36 * Props shared between {@link MySQLBranch} and {@link PostgresBranch}. The
37 * `database` and `parentBranch` fields are typed by each engine's resource.
38 */
39export interface BaseBranchProps {
40 /**
41 * Branch name. If omitted, a name is generated from the stack/stage/id.
42 */
43 name?: string;
44
45 /**
46 * If provided, restores the backup's schema and data to the new branch.
47 * Ignored if the branch already exists.
48 */
49 backupId?: string;
50
51 /**
52 * If provided, restores the last successful backup's schema and data.
53 * Ignored if the branch already exists.
54 */
55 seedData?: "last_successful_backup";
56
57 /**
58 * Whether safe migrations are enabled on the branch.
59 */
60 safeMigrations?: boolean;
61
62 /**
63 * Region in which to create the branch. Defaults to the database's
64 * region. The actual region is validated on adopt/update.
65 */
66 region?: { slug: string };
67
68 /**
69 * SQL migrations to apply against this branch. Accepts a directory path,
70 * a `Drizzle.Schema` resource, or `{ dir, table? }`. Bookkeeping lives
71 * in Alchemy's `__alchemy_migrations` table; drizzle/prisma history is
72 * converted one-way on first deploy.
73 */
74 migrations?: MigrationsInput;
75
76 /**
77 * Paths to additional `.sql` files to apply after migrations. Each file is
78 * hashed; only files whose contents change are re-applied on later deploys.
79 */
80 importFiles?: string[];
81}
82
83/**
84 * Attributes shared between {@link MySQLBranch} and {@link PostgresBranch}.
85 */
86export interface BaseBranchAttributes {
87 /** The branch name. */
88 name: string;
89 /** The PlanetScale organization slug. */
90 organization: string;
91 /** The database name. */
92 database: string;
93 /** The parent branch name. */
94 parentBranch: string;
95 /** Whether this is a production branch. */
96 production: boolean;
97 /** Time at which the branch was created (ISO 8601). */
98 createdAt: string;
99 /** Time at which the branch was last updated (ISO 8601). */
100 updatedAt: string;
101 /** HTML URL for accessing the branch in the dashboard. */
102 htmlUrl: string;
103 /** The region of the branch as reported by PlanetScale. */
104 region: { slug: string };
105 /** Directory containing migration files, if configured. */
106 migrationsDir: string | undefined;
107 /** Table used to track applied migrations, if configured. */
108 migrationsTable: string | undefined;
109 /** Content hashes for the last applied migration files. */
110 migrationsHashes: Record<string, string>;
111 /** Content hashes for the last applied import files. */
112 importHashes: Record<string, string>;
113 /**
114 * Desired total replica count requested by the resource input, when known.
115 * PlanetScale's branch read API exposes only boolean HA state, so this is
116 * persisted from Alchemy state rather than observed from live provider data.
117 */
118 desiredReplicas: number | undefined;
119 /** Whether the branch currently has HA replicas. */
120 hasReplicas: boolean | undefined;
121 /** Whether the branch currently has read-only replicas. */
122 hasReadOnlyReplicas: boolean | undefined;
123}
124
125// Runtime-resolved Database/Branch references. `news.database` can be a
126// plain string OR (after resolution) the Database resource's attributes —
127// which contain plain values for `name` / `organization`. The static
128// Database type has those fields as `Output<...>`, so we cast through a
129// structural shape here.
130type DatabaseRef = string | { name: string; organization?: string };
131type BranchRef = string | { name: string };
132
133const resolveDatabase = (
134 database: unknown,
135): { name: string; organization?: string } => {
136 const ref = database as DatabaseRef | undefined;
137 if (!ref) return { name: "" };
138 return typeof ref === "string"
139 ? { name: ref }
140 : { name: ref.name, organization: ref.organization };
141};
142
143const resolveParent = (parent: unknown): string => {
144 const ref = parent as BranchRef | undefined;
145 return !ref ? "main" : typeof ref === "string" ? ref : ref.name;
146};
147
148const createBranchName = (id: string, name: string | undefined) =>
149 Effect.gen(function* () {
150 return (
151 name ??
152 (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }))
153 );
154 });
155
156/**
157 * Shape of the engine-specific migration runners used by
158 * {@link makeBranchProvider}. MySQL and Postgres each supply their own
159 * implementations in their respective `*Migrations.ts` modules.
160 */
161export interface BranchMigrationRunners {
162 runMigrations: (
163 target: { organization: string; database: string; branch: string },
164 input: NormalizedMigrationsInput,
165 stamped: StampedMigrationsState,
166 ) => Effect.Effect<MigrationRun, any, any>;
167 runImports: (
168 target: { organization: string; database: string; branch: string },
169 importFiles: string[],
170 rootDir: string,
171 previousHashes: Record<string, string>,
172 ) => Effect.Effect<Record<string, string>, any, any>;
173}
174
175const rootDir = Effect.sync(() => process.cwd());
176
177/**
178 * Matches the UnprocessableEntity errors PlanetScale returns when a
179 * branch promotion/demotion races an in-flight cluster resize, e.g.
180 * "Branches with a cluster resize in progress cannot be demoted."
181 */
182const isResizeInProgress = (error: unknown): boolean =>
183 typeof error === "object" &&
184 error !== null &&
185 (error as { readonly _tag?: unknown })._tag === "UnprocessableEntity" &&
186 typeof (error as { readonly message?: unknown }).message === "string" &&
187 (error as { readonly message: string }).message.includes(
188 "cluster resize in progress",
189 );
190
191/**
192 * Build a branch provider for a specific PlanetScale engine. The
193 * caller supplies the typed `Resource` token and the engine's migration
194 * runners; everything else (observe / ensure / sync / delete) is shared.
195 */
196export const makeBranchProvider = <R extends ResourceLike>(opts: {
197 resource: ResourceClass<R>;
198 expectedKind: "mysql" | "postgresql";
199 engineLabel: string;
200 runners: BranchMigrationRunners;
201}) =>
202 Provider.succeed(opts.resource, {
203 stables: ["organization", "database"],
204
205 // PARENT FAN-OUT: PlanetScale branches are nested under a database within
206 // an organization, so there is no flat per-org branch enumeration API.
207 // Enumerate every database in the credentialed org, then list each
208 // database's branches concurrently (bounded), exhaustively paginating
209 // both levels, and keep only the branches whose engine kind matches this
210 // resource. Each item is hydrated into the exact `read` Attributes shape.
211 list: Effect.fn(function* () {
212 const { organization } = yield* yield* Credentials;
213
214 const databases = yield* planetscale.listDatabases
215 .pages({ organization })
216 .pipe(
217 Stream.runCollect,
218 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.data)),
219 );
220
221 const rows = yield* Effect.forEach(
222 databases,
223 (db) =>
224 planetscale.listBranches
225 .pages({ organization, database: db.name })
226 .pipe(
227 Stream.runCollect,
228 Effect.map((chunk) =>
229 Array.from(chunk).flatMap((page) =>
230 page.data
231 .filter((branch) => branch.kind === opts.expectedKind)
232 .map(
233 (branch) =>
234 ({
235 name: branch.name,
236 organization,
237 database: db.name,
238 parentBranch: branch.parent_branch ?? "main",
239 production: branch.production,
240 createdAt: branch.created_at,
241 updatedAt: branch.updated_at,
242 htmlUrl: branch.html_url,
243 region: { slug: branch.region.slug },
244 migrationsDir: undefined,
245 migrationsTable: undefined,
246 migrationsHashes: {},
247 importHashes: {},
248 desiredReplicas: undefined,
249 hasReplicas: branch.has_replicas,
250 hasReadOnlyReplicas: branch.has_read_only_replicas,
251 }) satisfies BaseBranchAttributes,
252 ),
253 ),
254 ),
255 // A database can be deleted between enumeration and the
256 // per-database branch list — skip it rather than fail.
257 Effect.catchTag("NotFound", () =>
258 Effect.succeed([] as BaseBranchAttributes[]),
259 ),
260 ),
261 { concurrency: 10 },
262 );
263
264 return rows.flat();
265 }),
266
267 diff: Effect.fn(function* ({ news, olds, output }: any) {
268 if (!isResolved(news)) return undefined;
269
270 // Branch names are rename-mutable (reconcile syncs `news.name` in
271 // place), so `name` cannot live in the provider-level stables. But
272 // almost no update is a rename — for those, advertise `name` as
273 // stable on the update so downstream consumers referencing this
274 // branch still resolve `branch.name` at plan time. Without it, a
275 // metadata-only update (e.g. an embedded database ref whose
276 // `migrationsHashes` moved) resolves consumer refs to just
277 // `{ organization, database }`, and identity-sensitive consumers
278 // (PostgresRole) falsely plan a replacement against `name: undefined`.
279 //
280 // The name only changes when the `name` prop itself changes: an
281 // explicit name renames iff it differs from the observed name, and
282 // an omitted name is engine-generated deterministically (stable
283 // across updates — the instance id only rotates on replacement).
284 const nameIsStable =
285 output?.name !== undefined &&
286 (news.name !== undefined
287 ? news.name === output.name
288 : olds?.name === undefined);
289 const stables = nameIsStable
290 ? ["organization", "database", "name"]
291 : undefined;
292
293 const newDb = resolveDatabase(news.database).name;
294 const oldDbRef = output?.database ?? olds.database;
295 if (oldDbRef) {
296 const oldDb = resolveDatabase(oldDbRef).name;
297 if (newDb !== oldDb) {
298 return { action: "replace" } as const;
299 }
300 }
301
302 const newParent = resolveParent(news.parentBranch);
303 const oldParent =
304 output?.parentBranch ?? resolveParent(olds.parentBranch);
305 if (newParent !== oldParent) {
306 return { action: "replace" } as const;
307 }
308
309 if (
310 news.region?.slug &&
311 output?.region?.slug &&
312 news.region.slug !== output.region.slug
313 ) {
314 return { action: "replace" } as const;
315 }
316
317 if (news.replicas !== undefined) {
318 if (output?.desiredReplicas !== news.replicas) {
319 return { action: "update", stables } as const;
320 }
321
322 const desiredHasReplicas = news.replicas > 0;
323 if (
324 output?.hasReplicas !== undefined &&
325 output.hasReplicas !== desiredHasReplicas
326 ) {
327 return { action: "update", stables } as const;
328 }
329 }
330
331 if (yield* diffMigrations({ news, output })) {
332 return { action: "update", stables } as const;
333 }
334 if (news.importFiles?.length) {
335 const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
336 if (!recordsEqual(newHashes, output?.importHashes ?? {})) {
337 return { action: "update", stables } as const;
338 }
339 }
340
341 // Remaining prop changes (rename, safeMigrations, clusterSize,
342 // metadata embedded in resource refs) are all in-place updates.
343 // Decide them here instead of falling back to the engine's default
344 // deep-compare so the conditional `name` stable above is attached —
345 // the default path uses the provider-level stables, which strip
346 // `name` from downstream plan resolution.
347 if (havePropsChanged(olds, news)) {
348 return { action: "update", stables } as const;
349 }
350
351 return undefined;
352 }),
353
354 read: Effect.fn(function* ({ id, olds, output }: any) {
355 // If we have neither a cached output nor a usable `olds.database`
356 // there's no way to identify which branch to refresh — most often
357 // this is destroy of a partially-created resource whose props
358 // never got fully persisted. Return `undefined` so the engine
359 // treats it as already-gone and drops the state entry.
360 if (!output && !olds?.database) {
361 return undefined;
362 }
363 const dbInfo = output
364 ? { name: output.database, organization: output.organization }
365 : resolveDatabase(olds.database);
366 const { organization: envOrg } = yield* yield* Credentials;
367 const organization =
368 output?.organization ?? dbInfo.organization ?? envOrg;
369 const databaseName = output?.database ?? dbInfo.name;
370 const branchName =
371 output?.name ?? (yield* createBranchName(id, olds.name));
372
373 return yield* planetscale
374 .getBranch({
375 organization,
376 database: databaseName,
377 branch: branchName,
378 })
379 .pipe(
380 Effect.map((data) => {
381 const attrs = {
382 name: data.name,
383 organization,
384 database: databaseName,
385 parentBranch: data.parent_branch ?? "main",
386 production: data.production,
387 createdAt: data.created_at,
388 updatedAt: data.updated_at,
389 htmlUrl: data.html_url,
390 region: { slug: data.region.slug },
391 migrationsDir: output?.migrationsDir ?? olds?.migrationsDir,
392 migrationsTable: output?.migrationsTable ?? olds?.migrationsTable,
393 migrationsHashes: output?.migrationsHashes ?? {},
394 importHashes: output?.importHashes ?? {},
395 desiredReplicas:
396 output?.desiredReplicas ??
397 olds?.desiredReplicas ??
398 olds?.replicas,
399 hasReplicas: data.has_replicas,
400 hasReadOnlyReplicas: data.has_read_only_replicas,
401 } satisfies BaseBranchAttributes;
402
403 return output ? attrs : Unowned(attrs);
404 }),
405 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
406 );
407 }),
408
409 reconcile: Effect.fn(function* ({ id, news, output, session }: any) {
410 const { organization: envOrg } = yield* yield* Credentials;
411 const dbInfo = resolveDatabase(news.database);
412 const organization =
413 output?.organization ?? dbInfo.organization ?? envOrg;
414 const databaseName = output?.database ?? dbInfo.name;
415 const desiredBranchName = yield* createBranchName(id, news.name);
416 const observedBranchName = output?.name ?? desiredBranchName;
417 const parentBranchName = resolveParent(news.parentBranch);
418
419 // If parentBranch is a plain string (an unmanaged branch reference),
420 // wait for it to be ready before we touch the child branch. When it
421 // is a Branch resource, Alchemy's resource graph already guarantees
422 // readiness before this resource's inputs are resolved.
423 if (news.parentBranch && typeof news.parentBranch === "string") {
424 yield* waitForBranchReady(
425 organization,
426 databaseName,
427 parentBranchName,
428 session,
429 );
430 }
431
432 // Observe — fetch the live branch state.
433 let current = yield* planetscale
434 .getBranch({
435 organization,
436 database: databaseName,
437 branch: observedBranchName,
438 })
439 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
440
441 // Ensure — if missing, create. The parent must be ready before
442 // the child can fork from it. PostgreSQL short cluster sizes need
443 // to be expanded against the parent's region so the API call lands
444 // with a valid sku.
445 if (!current) {
446 const parent = yield* waitForBranchReady(
447 organization,
448 databaseName,
449 parentBranchName,
450 session,
451 );
452 const parentClusterSize = news.clusterSize
453 ? parent.kind === "postgresql"
454 ? toPostgresClusterSku({
455 size: news.clusterSize,
456 region: parent.region.slug,
457 })
458 : news.clusterSize
459 : undefined;
460 yield* session.note("Creating branch...");
461 current = yield* planetscale.createBranch({
462 organization,
463 database: databaseName,
464 name: desiredBranchName,
465 parent_branch: parentBranchName,
466 backup_id: news.backupId,
467 seed_data: news.seedData,
468 region: news.region?.slug,
469 cluster_size: parentClusterSize,
470 });
471 }
472
473 if (current.kind !== opts.expectedKind) {
474 return yield* Effect.fail(
475 new PlanetscaleConflict({
476 message:
477 `Planetscale branch "${current.name}" (database "${databaseName}") has kind ` +
478 `"${current.kind}" but this resource is a ${opts.engineLabel}. ` +
479 `Use the matching ${current.kind === "mysql" ? "MySQLBranch" : "PostgresBranch"} ` +
480 `resource instead.`,
481 }),
482 );
483 }
484
485 yield* waitForBranchReady(
486 organization,
487 databaseName,
488 current.name,
489 session,
490 );
491
492 // Sync name — branch names are mutable. Continue subsequent syncs
493 // under the name returned by the API.
494 if (current.name !== desiredBranchName) {
495 current = yield* planetscale.updateBranch({
496 organization,
497 database: databaseName,
498 branch: current.name,
499 new_name: desiredBranchName,
500 });
501 }
502
503 const branchName = current.name;
504
505 // Sync production status before branch settings that depend on it.
506 // PlanetScale only supports explicit branch promotion/demotion for MySQL.
507 if (opts.expectedKind === "mysql") {
508 const desiredProduction = news.isProduction ?? false;
509 if (current.production !== desiredProduction) {
510 // A keyspace can report `resizing: false` while the resize
511 // workflow is still finalizing, during which promote/demote is
512 // rejected with an UnprocessableEntity. Retry until it clears.
513 const retryWhileResizing = Effect.retry({
514 while: isResizeInProgress,
515 schedule: Schedule.max([
516 Schedule.spaced("5 seconds"),
517 Schedule.recurs(120),
518 ]),
519 });
520 current = desiredProduction
521 ? yield* planetscale
522 .promoteBranch({
523 organization,
524 database: databaseName,
525 branch: branchName,
526 })
527 .pipe(retryWhileResizing)
528 : yield* planetscale
529 .demoteBranch({
530 organization,
531 database: databaseName,
532 branch: branchName,
533 })
534 .pipe(retryWhileResizing);
535 }
536 }
537
538 // Sync safeMigrations — observed via `current.safe_migrations`,
539 // skip the API call entirely on no-op.
540 if (
541 news.safeMigrations !== undefined &&
542 current.safe_migrations !== news.safeMigrations
543 ) {
544 if (news.safeMigrations) {
545 yield* planetscale.enableSafeMigrations({
546 organization,
547 database: databaseName,
548 branch: branchName,
549 });
550 } else {
551 yield* planetscale.disableSafeMigrations({
552 organization,
553 database: databaseName,
554 branch: branchName,
555 });
556 }
557 }
558
559 if (opts.expectedKind === "postgresql" && news.replicas !== undefined) {
560 const desiredReplicas = news.replicas;
561 const desiredHasReplicas = desiredReplicas > 0;
562 const shouldApplyReplicaChange =
563 current.has_replicas !== desiredHasReplicas ||
564 (desiredHasReplicas && output?.desiredReplicas !== desiredReplicas);
565
566 if (shouldApplyReplicaChange) {
567 yield* waitForPendingPostgresChanges(
568 organization,
569 databaseName,
570 branchName,
571 );
572 const change = yield* planetscale.updateBranchChangeRequest({
573 organization,
574 database: databaseName,
575 branch: branchName,
576 replicas: desiredReplicas,
577 });
578 yield* waitForPendingPostgresChanges(
579 organization,
580 databaseName,
581 branchName,
582 change.id,
583 );
584 current = yield* planetscale.getBranch({
585 organization,
586 database: databaseName,
587 branch: branchName,
588 });
589 }
590 }
591
592 // Sync clusterSize — only meaningful on production branches.
593 if (news.clusterSize) {
594 if (current.kind === "postgresql") {
595 yield* ensurePostgresProductionBranchClusterSize(
596 organization,
597 databaseName,
598 branchName,
599 news.clusterSize,
600 );
601 } else {
602 yield* ensureMySQLProductionBranchClusterSize(
603 organization,
604 databaseName,
605 branchName,
606 news.clusterSize,
607 );
608 }
609 }
610
611 // Re-read so the returned attributes reflect any mutation.
612 const updated = yield* planetscale.getBranch({
613 organization,
614 database: databaseName,
615 branch: branchName,
616 });
617
618 const migrationTarget = {
619 organization,
620 database: databaseName,
621 branch: updated.name,
622 };
623
624 const migrationsInput = migrationsInputOf(news);
625 const migrations = migrationsInput
626 ? yield* opts.runners.runMigrations(
627 migrationTarget,
628 migrationsInput,
629 stampedOf(output),
630 )
631 : undefined;
632 const importHashes = news.importFiles?.length
633 ? yield* opts.runners.runImports(
634 migrationTarget,
635 news.importFiles,
636 yield* rootDir,
637 output?.importHashes ?? {},
638 )
639 : {};
640
641 return {
642 name: updated.name,
643 organization,
644 database: databaseName,
645 parentBranch: updated.parent_branch ?? parentBranchName,
646 production: updated.production,
647 createdAt: updated.created_at,
648 updatedAt: updated.updated_at,
649 htmlUrl: updated.html_url,
650 region: { slug: updated.region.slug },
651 ...migrationsAttrs({ input: migrationsInput, run: migrations, output }),
652 importHashes,
653 desiredReplicas: news.replicas ?? output?.desiredReplicas,
654 hasReplicas: updated.has_replicas,
655 hasReadOnlyReplicas: updated.has_read_only_replicas,
656 } satisfies BaseBranchAttributes;
657 }),
658
659 delete: Effect.fn(function* ({ output }: any) {
660 // If `read` returned undefined (e.g. destroy of a partially-
661 // created branch whose props never finished persisting), there
662 // is nothing addressable to delete. Drop the state entry.
663 if (!output) return;
664 yield* planetscale
665 .deleteBranch({
666 organization: output.organization,
667 database: output.database,
668 branch: output.name,
669 })
670 .pipe(
671 Effect.catchTag("NotFound", () => Effect.void),
672 Effect.catchIf(
673 isKnownError(
674 "UnprocessableEntity",
675 "The default branch cannot be deleted.",
676 ),
677 () => Effect.void,
678 ),
679 );
680 }),
681 } as any);