| 1 | import * as planetscale from "@distilled.cloud/planetscale"; |
| 2 | import { Credentials } from "@distilled.cloud/planetscale/Credentials"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../AdoptPolicy.ts"; |
| 7 | import { havePropsChanged, isResolved } from "../Diff.ts"; |
| 8 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 9 | import * as Provider from "../Provider.ts"; |
| 10 | import type { ResourceClass, ResourceLike } from "../Resource.ts"; |
| 11 | import { hashImports, hashMigrations } from "../SQL/SqlFile.ts"; |
| 12 | import { recordsEqual } from "../Util/equal.ts"; |
| 13 | import { ensureMySQLProductionBranchClusterSize } from "./MySQL/MySQLClusterSize.ts"; |
| 14 | import { |
| 15 | ensurePostgresProductionBranchClusterSize, |
| 16 | toPostgresClusterSku, |
| 17 | waitForPendingPostgresChanges, |
| 18 | } from "./Postgres/PostgresClusterSize.ts"; |
| 19 | import { |
| 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"; |
| 29 | import { |
| 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 | */ |
| 39 | export 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 | */ |
| 86 | export 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. |
| 130 | type DatabaseRef = string | { name: string; organization?: string }; |
| 131 | type BranchRef = string | { name: string }; |
| 132 | |
| 133 | const 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 | |
| 143 | const resolveParent = (parent: unknown): string => { |
| 144 | const ref = parent as BranchRef | undefined; |
| 145 | return !ref ? "main" : typeof ref === "string" ? ref : ref.name; |
| 146 | }; |
| 147 | |
| 148 | const 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 | */ |
| 161 | export 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 | |
| 175 | const 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 | */ |
| 182 | const 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 | */ |
| 196 | export 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); |