@alchemy.run/planetscale 0.0.0 → 2.0.0-beta.85

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