@alchemy.run/planetscale 0.0.0-stage → 2.0.0-beta.84

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 +87 -2
  3. package/lib/AuthProvider.d.ts +64 -0
  4. package/lib/AuthProvider.d.ts.map +1 -0
  5. package/lib/AuthProvider.js +384 -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 +65 -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 +24 -0
  36. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
  37. package/lib/MySQL/MySQLMigrations.js +106 -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 +24 -0
  72. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
  73. package/lib/Postgres/PostgresMigrations.js +116 -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 +94 -3
  100. package/src/AuthProvider.ts +571 -0
  101. package/src/Branch.ts +657 -0
  102. package/src/Contributions.ts +9 -0
  103. package/src/Credentials.ts +116 -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 +182 -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 +193 -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
@@ -0,0 +1,521 @@
1
+ import {
2
+ diffMigrations,
3
+ migrationsAttrs,
4
+ migrationsInputOf,
5
+ stampedOf,
6
+ } from "@alchemy.run/sql/Migrations";
7
+ import { hashImports } from "@alchemy.run/sql/SqlFile";
8
+ import * as planetscale from "@distilled.cloud/planetscale";
9
+ import { Credentials } from "@distilled.cloud/planetscale/Credentials";
10
+ import { havePropsChanged, isResolved } from "alchemy/Diff";
11
+ import { createPhysicalName } from "alchemy/PhysicalName";
12
+ import * as Provider from "alchemy/Provider";
13
+ import { Resource } from "alchemy/Resource";
14
+ import { recordsEqual } from "alchemy/Util/equal";
15
+ import * as Effect from "effect/Effect";
16
+ import * as Stream from "effect/Stream";
17
+ import type { BaseDatabaseAttributes, BaseDatabaseProps } from "../Database.ts";
18
+ import type { Providers } from "../Providers.ts";
19
+ import {
20
+ deleteUnprotectedDatabase,
21
+ PlanetscaleConflict,
22
+ replaceDatabase,
23
+ waitForBranchReady,
24
+ waitForDatabaseReady,
25
+ } from "../Util.ts";
26
+ import {
27
+ ensureMySQLProductionBranchClusterSize,
28
+ observeDefaultKeyspaceReplicas,
29
+ type MySQLClusterSize,
30
+ } from "./MySQLClusterSize.ts";
31
+ import { runMySQLImports, runMySQLMigrations } from "./MySQLMigrations.ts";
32
+
33
+ /**
34
+ * Properties for creating or updating a MySQL PlanetScale database.
35
+ */
36
+ export interface MySQLDatabaseProps extends BaseDatabaseProps {
37
+ /**
38
+ * The MySQL database cluster size. Required.
39
+ * @see https://planetscale.com/docs/concepts/cluster-size
40
+ */
41
+ clusterSize: MySQLClusterSize;
42
+
43
+ /**
44
+ * Desired total number of replicas for the database's default keyspace.
45
+ * Each cluster size includes a fixed number of replicas (2 for
46
+ * production `PS_*` sizes); higher values add extra replicas. Changes
47
+ * are reconciled in place via PlanetScale's keyspace resize lifecycle —
48
+ * never by replacing the database.
49
+ * @see https://planetscale.com/docs/vitess/sharding/keyspaces
50
+ */
51
+ replicas?: number;
52
+
53
+ /**
54
+ * Whether to copy migration data to new branches and in deploy requests.
55
+ */
56
+ automaticMigrations?: boolean;
57
+
58
+ /**
59
+ * Migration framework to use on the database.
60
+ */
61
+ migrationFramework?: string;
62
+
63
+ /**
64
+ * Name of the migration table.
65
+ */
66
+ migrationTableName?: string;
67
+
68
+ /**
69
+ * Whether data branching is allowed on the database.
70
+ */
71
+ allowDataBranching?: boolean;
72
+
73
+ /**
74
+ * Whether foreign key constraints are allowed on the database.
75
+ */
76
+ allowForeignKeyConstraints?: boolean;
77
+
78
+ /**
79
+ * Whether full queries should be collected from the database.
80
+ */
81
+ insightsRawQueries?: boolean;
82
+ }
83
+
84
+ /**
85
+ * Output attributes of a deployed MySQL PlanetScale database.
86
+ */
87
+ export interface MySQLDatabaseAttributes extends BaseDatabaseAttributes {
88
+ /**
89
+ * Observed total replica count of the default keyspace on the default
90
+ * branch, or `undefined` while the keyspace is still provisioning.
91
+ */
92
+ replicas: number | undefined;
93
+
94
+ /**
95
+ * Whether to copy migration data to new branches and in deploy requests.
96
+ */
97
+ automaticMigrations: boolean;
98
+
99
+ /**
100
+ * Migration framework to use on the database.
101
+ */
102
+ migrationFramework?: string;
103
+
104
+ /**
105
+ * Name of the migration table.
106
+ */
107
+ migrationTableName?: string;
108
+
109
+ /**
110
+ * Whether data branching is allowed on the database.
111
+ */
112
+ allowDataBranching: boolean;
113
+
114
+ /**
115
+ * Whether foreign key constraints are allowed on the database.
116
+ */
117
+ allowForeignKeyConstraints: boolean;
118
+
119
+ /**
120
+ * Whether full queries should be collected from the database.
121
+ */
122
+ insightsRawQueries: boolean;
123
+ }
124
+
125
+ /**
126
+ * A MySQL PlanetScale database (powered by Vitess). For PostgreSQL use
127
+ * {@link PostgresDatabase} instead.
128
+ *
129
+ * ### Creating a MySQL Database
130
+ * **Example:** Basic MySQL database
131
+ * ```typescript
132
+ * const db = yield* Planetscale.MySQLDatabase("MyDb", {
133
+ * clusterSize: "PS_10",
134
+ * });
135
+ * ```
136
+ *
137
+ * **Example:** MySQL with Vitess migration tooling
138
+ * ```typescript
139
+ * const db = yield* Planetscale.MySQLDatabase("MyDb", {
140
+ * clusterSize: "PS_10",
141
+ * automaticMigrations: true,
142
+ * migrationFramework: "rails",
143
+ * migrationTableName: "schema_migrations",
144
+ * allowDataBranching: true,
145
+ * });
146
+ * ```
147
+ *
148
+ * ### Migrations and seed data
149
+ * **Example:** Apply migrations and seed files
150
+ * ```typescript
151
+ * const db = yield* Planetscale.MySQLDatabase("MyDb", {
152
+ * clusterSize: "PS_10",
153
+ * migrationsDir: "./migrations/mysql",
154
+ * importFiles: ["./seed/mysql.sql"],
155
+ * });
156
+ * ```
157
+ *
158
+ * ### Deletion protection
159
+ * **Example:** Refuse deletes of a production database
160
+ * ```typescript
161
+ * const db = yield* Planetscale.MySQLDatabase("MyDb", {
162
+ * clusterSize: "PS_10",
163
+ * deletionProtection: true,
164
+ * });
165
+ * ```
166
+ *
167
+ * ### Adoption
168
+ * **Example:** Adopting an existing database
169
+ * ```typescript
170
+ * import { adopt } from "alchemy/AdoptPolicy";
171
+ *
172
+ * const db = yield* Planetscale.MySQLDatabase("Existing", {
173
+ * name: "existing-db",
174
+ * clusterSize: "PS_10",
175
+ * }).pipe(adopt());
176
+ * ```
177
+ */
178
+ export type MySQLDatabase = Resource<
179
+ "Planetscale.MySQLDatabase",
180
+ MySQLDatabaseProps,
181
+ MySQLDatabaseAttributes,
182
+ never,
183
+ Providers
184
+ >;
185
+
186
+ /** @resource */
187
+ export const MySQLDatabase = Resource<MySQLDatabase>("Planetscale.MySQLDatabase");
188
+
189
+ export const MySQLDatabaseProvider = () =>
190
+ Provider.succeed(MySQLDatabase, {
191
+ stables: ["id", "organization", "region"],
192
+ diff: Effect.fn(function* ({ news, olds, output }) {
193
+ if (!isResolved(news)) return undefined;
194
+
195
+ // Database names are rename-mutable (reconcile folds `new_name`
196
+ // into the settings sync), so `name` cannot live in the
197
+ // provider-level stables. Almost no update is a rename though —
198
+ // for those, advertise `name` as stable on the update so
199
+ // downstream consumers (branches, passwords) still resolve
200
+ // `database.name` at plan time instead of seeing `undefined` and
201
+ // falsely planning a replacement. The name only changes when the
202
+ // `name` prop itself changes: an explicit name renames iff it
203
+ // differs from the observed name, and an omitted name is
204
+ // engine-generated deterministically (stable across updates).
205
+ const nameIsStable =
206
+ output?.name !== undefined &&
207
+ (news.name !== undefined ? news.name === output.name : olds?.name === undefined);
208
+ const stables = nameIsStable ? ["id", "organization", "region", "name"] : undefined;
209
+
210
+ if (
211
+ news.region?.slug !== undefined &&
212
+ output?.region?.slug !== undefined &&
213
+ news.region.slug !== output.region.slug
214
+ ) {
215
+ return yield* replaceDatabase(news, output, "region");
216
+ }
217
+ // Replicas reconcile in place via a keyspace resize — never a
218
+ // replacement. Diff against the observed keyspace replica count so
219
+ // an adopted database whose live state already matches plans no-op.
220
+ if (news.replicas !== undefined && news.replicas !== (output?.replicas ?? olds.replicas)) {
221
+ return { action: "update", stables } as const;
222
+ }
223
+ if (yield* diffMigrations({ news, output })) {
224
+ return { action: "update", stables } as const;
225
+ }
226
+ if (news.importFiles?.length) {
227
+ const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
228
+ if (!recordsEqual(newHashes, output?.importHashes ?? {})) {
229
+ return { action: "update", stables } as const;
230
+ }
231
+ }
232
+ // Remaining prop changes (rename, settings, clusterSize, …) are
233
+ // in-place updates. Decide them here instead of falling back to
234
+ // the engine's default deep-compare so the conditional `name`
235
+ // stable above is attached — the default path uses the
236
+ // provider-level stables, which strip `name` from downstream plan
237
+ // resolution.
238
+ if (havePropsChanged(olds, news)) {
239
+ return { action: "update", stables } as const;
240
+ }
241
+
242
+ // Nothing changed. Still advertise the conditional `name` stable so
243
+ // a `--force` deploy (which upgrades this noop to an update) keeps
244
+ // `name` resolvable downstream instead of falsely replacing
245
+ // consumers such as roles and passwords (#1832).
246
+ return stables ? ({ action: "noop", stables } as const) : undefined;
247
+ }),
248
+
249
+ read: Effect.fn(function* ({ id, output, olds }) {
250
+ const { organization } = yield* yield* Credentials;
251
+ const databaseName = output?.name ?? (yield* createDatabaseName(id, olds?.name));
252
+ return yield* planetscale
253
+ .getDatabase({
254
+ organization,
255
+ database: databaseName,
256
+ })
257
+ .pipe(
258
+ Effect.flatMap(
259
+ Effect.fn(function* (data) {
260
+ if (data.kind !== "mysql") {
261
+ return yield* new PlanetscaleConflict({
262
+ message:
263
+ `Planetscale database "${data.name}" has kind "${data.kind}" but this resource ` +
264
+ `is a MySQLDatabase. Use Planetscale.${data.kind === "postgresql" ? "PostgresDatabase" : data.kind}() instead, ` +
265
+ `or delete the existing database and retry.`,
266
+ });
267
+ }
268
+ // Observe the default keyspace's live replica configuration
269
+ // so adopted databases diff against reality.
270
+ const replicas = yield* observeDefaultKeyspaceReplicas(
271
+ organization,
272
+ data.name,
273
+ data.default_branch ?? "main",
274
+ );
275
+ return {
276
+ id: data.id,
277
+ name: data.name,
278
+ organization,
279
+ state: data.state,
280
+ defaultBranch: data.default_branch ?? "main",
281
+ replicas,
282
+ plan: data.plan ?? "hobby",
283
+ createdAt: data.created_at,
284
+ updatedAt: data.updated_at,
285
+ htmlUrl: data.html_url,
286
+ region: { slug: data.region.slug },
287
+ migrationsDir: output?.migrationsDir ?? (olds && migrationsInputOf(olds))?.dir,
288
+ migrationsTable:
289
+ output?.migrationsTable ?? (olds && migrationsInputOf(olds))?.table,
290
+ migrationsHashes: output?.migrationsHashes ?? {},
291
+ importHashes: output?.importHashes ?? {},
292
+ clusterSize: output?.clusterSize ?? "",
293
+ requireApprovalForDeploy: data.require_approval_for_deploy ?? false,
294
+ restrictBranchRegion: data.restrict_branch_region ?? false,
295
+ insightsRawQueries: data.insights_raw_queries ?? false,
296
+ productionBranchWebConsole: data.production_branch_web_console ?? false,
297
+ deletionProtection: data.deletion_protected ?? false,
298
+ automaticMigrations: data.automatic_migrations ?? false,
299
+ migrationFramework: data.migration_framework ?? undefined,
300
+ migrationTableName: data.migration_table_name ?? undefined,
301
+ allowDataBranching: data.allow_data_branching ?? false,
302
+ allowForeignKeyConstraints: data.foreign_keys_enabled ?? false,
303
+ };
304
+ }),
305
+ ),
306
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
307
+ );
308
+ }),
309
+
310
+ reconcile: Effect.fn(function* ({ id, news, output, session }) {
311
+ const { organization } = yield* yield* Credentials;
312
+ const newName = yield* createDatabaseName(id, news.name);
313
+ const clusterSize = news.clusterSize;
314
+
315
+ // Observe — read live state under either the cached name (rename
316
+ // / refresh) or the freshly-derived name (greenfield). Observing
317
+ // unconditionally absorbs the race between `read` and `reconcile`
318
+ // — if a foreign actor (or a previously-failed-to-persist
319
+ // create) produced a database under the desired name in the
320
+ // meantime, we'll find it here and skip the duplicate create.
321
+ // Adoption routing has already been gated by `read` returning
322
+ // `Unowned`, so by the time we reach reconcile any observed
323
+ // database is fair game.
324
+ const observedName = output?.name ?? newName;
325
+ let observed = yield* planetscale
326
+ .getDatabase({
327
+ organization,
328
+ database: observedName,
329
+ })
330
+ .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
331
+
332
+ // Ensure — if missing, create.
333
+ if (!observed) {
334
+ yield* session.note("Creating database...");
335
+ // Note: the API rejects the `replicas` param for mysql databases;
336
+ // replicas are converged below via the keyspace resize lifecycle.
337
+ observed = yield* planetscale.createDatabase({
338
+ organization,
339
+ name: newName,
340
+ region: news.region?.slug,
341
+ kind: "mysql",
342
+ cluster_size: clusterSize,
343
+ });
344
+ }
345
+
346
+ // Wait for the database to finish provisioning before any
347
+ // downstream sync runs. PlanetScale reports `state: "pending"`
348
+ // for a while after createDatabase returns, during which branch
349
+ // operations on this database (including our own settings PATCH)
350
+ // race against the provisioning fiber.
351
+ yield* waitForDatabaseReady(organization, observed.name, session);
352
+
353
+ if (observed.kind !== "mysql") {
354
+ return yield* new PlanetscaleConflict({
355
+ message:
356
+ `Planetscale database "${observed.name}" has kind "${observed.kind}" but this resource ` +
357
+ `is a MySQLDatabase. Use Planetscale.${observed.kind === "postgresql" ? "PostgresDatabase" : observed.kind}() instead, ` +
358
+ `or delete the existing database and retry.`,
359
+ });
360
+ }
361
+
362
+ // Sync — ensure a non-`main` default branch exists before
363
+ // referencing it via `default_branch`. The branch is created
364
+ // empty (no cluster size yet); we'll size it below.
365
+ if (news.defaultBranch && news.defaultBranch !== "main") {
366
+ yield* waitForDatabaseReady(organization, observed.name, session);
367
+ const branchExists = yield* planetscale
368
+ .getBranch({
369
+ organization,
370
+ database: observed.name,
371
+ branch: news.defaultBranch,
372
+ })
373
+ .pipe(
374
+ Effect.map(() => true),
375
+ Effect.catchTag("NotFound", () => Effect.succeed(false)),
376
+ );
377
+ if (!branchExists) {
378
+ yield* planetscale.createBranch({
379
+ organization,
380
+ database: observed.name,
381
+ name: news.defaultBranch,
382
+ parent_branch: "main",
383
+ // create branch with cluster size to skip resizing when promoting the branch
384
+ cluster_size: clusterSize,
385
+ });
386
+ }
387
+ }
388
+
389
+ // Sync settings — `updateSettings` is upsert-shaped, so we can
390
+ // call it with the full desired payload on every reconcile.
391
+ const updated = yield* planetscale.updateDatabaseSettings({
392
+ organization,
393
+ database: observed.name,
394
+ new_name: newName !== observed.name ? newName : undefined,
395
+ automatic_migrations: news.automaticMigrations,
396
+ migration_framework: news.migrationFramework,
397
+ migration_table_name: news.migrationTableName,
398
+ allow_foreign_key_constraints: news.allowForeignKeyConstraints,
399
+ allow_data_branching: news.allowDataBranching,
400
+ require_approval_for_deploy: news.requireApprovalForDeploy,
401
+ restrict_branch_region: news.restrictBranchRegion,
402
+ insights_raw_queries: news.insightsRawQueries,
403
+ production_branch_web_console: news.productionBranchWebConsole,
404
+ deletion_protected: news.deletionProtection,
405
+ default_branch: news.defaultBranch,
406
+ });
407
+
408
+ // Sync cluster size and replicas on the active default branch —
409
+ // both converge in place via PlanetScale's keyspace resize
410
+ // lifecycle, diffed against the observed default keyspace.
411
+ const branch = news.defaultBranch ?? updated.default_branch ?? "main";
412
+ const keyspace = yield* ensureMySQLProductionBranchClusterSize(
413
+ organization,
414
+ updated.name,
415
+ branch,
416
+ news.clusterSize,
417
+ news.replicas,
418
+ );
419
+
420
+ const migrationTarget = {
421
+ organization,
422
+ database: updated.name,
423
+ branch,
424
+ };
425
+ const migrationsInput = migrationsInputOf(news);
426
+ if (migrationsInput || news.importFiles?.length) {
427
+ yield* waitForBranchReady(organization, updated.name, branch, session);
428
+ }
429
+ const migrations = migrationsInput
430
+ ? yield* runMySQLMigrations(migrationTarget, migrationsInput, stampedOf(output))
431
+ : undefined;
432
+ const importHashes = news.importFiles?.length
433
+ ? yield* runMySQLImports(
434
+ migrationTarget,
435
+ news.importFiles,
436
+ yield* rootDir,
437
+ output?.importHashes ?? {},
438
+ )
439
+ : {};
440
+
441
+ return {
442
+ id: updated.id,
443
+ name: updated.name,
444
+ organization,
445
+ state: updated.state,
446
+ defaultBranch: updated.default_branch ?? branch,
447
+ plan: updated.plan ?? output?.plan ?? "hobby",
448
+ createdAt: updated.created_at,
449
+ updatedAt: updated.updated_at,
450
+ htmlUrl: updated.html_url,
451
+ region: { slug: updated.region.slug },
452
+ clusterSize,
453
+ replicas: keyspace.replicas,
454
+ ...migrationsAttrs({ input: migrationsInput, run: migrations, output }),
455
+ importHashes,
456
+ requireApprovalForDeploy: updated.require_approval_for_deploy ?? false,
457
+ restrictBranchRegion: updated.restrict_branch_region ?? false,
458
+ insightsRawQueries: updated.insights_raw_queries ?? false,
459
+ productionBranchWebConsole: updated.production_branch_web_console ?? false,
460
+ deletionProtection: updated.deletion_protected ?? false,
461
+ automaticMigrations: updated.automatic_migrations ?? false,
462
+ migrationFramework: updated.migration_framework ?? undefined,
463
+ migrationTableName: updated.migration_table_name ?? undefined,
464
+ allowDataBranching: updated.allow_data_branching ?? false,
465
+ allowForeignKeyConstraints: updated.foreign_keys_enabled ?? false,
466
+ } satisfies MySQLDatabaseAttributes;
467
+ }),
468
+
469
+ delete: Effect.fn(function* ({ output }) {
470
+ yield* deleteUnprotectedDatabase(output.organization, output.name);
471
+ }),
472
+
473
+ list: Effect.fn(function* () {
474
+ const { organization } = yield* yield* Credentials;
475
+
476
+ return yield* planetscale.listDatabases.pages({ organization }).pipe(
477
+ Stream.runCollect,
478
+ Effect.map((chunk) =>
479
+ Array.from(chunk).flatMap((page) =>
480
+ page.data
481
+ .filter((db) => db.kind === "mysql")
482
+ .map((data): MySQLDatabase["Attributes"] => ({
483
+ id: data.id,
484
+ name: data.name,
485
+ organization,
486
+ state: data.state,
487
+ defaultBranch: data.default_branch ?? "main",
488
+ plan: data.plan ?? "hobby",
489
+ createdAt: data.created_at,
490
+ updatedAt: data.updated_at,
491
+ htmlUrl: data.html_url,
492
+ region: { slug: data.region.slug },
493
+ migrationsDir: undefined,
494
+ migrationsTable: undefined,
495
+ migrationsHashes: {},
496
+ importHashes: {},
497
+ clusterSize: "",
498
+ replicas: undefined,
499
+ requireApprovalForDeploy: data.require_approval_for_deploy ?? false,
500
+ restrictBranchRegion: data.restrict_branch_region ?? false,
501
+ insightsRawQueries: data.insights_raw_queries ?? false,
502
+ productionBranchWebConsole: data.production_branch_web_console ?? false,
503
+ deletionProtection: data.deletion_protected ?? false,
504
+ automaticMigrations: data.automatic_migrations ?? false,
505
+ migrationFramework: data.migration_framework ?? undefined,
506
+ migrationTableName: data.migration_table_name ?? undefined,
507
+ allowDataBranching: data.allow_data_branching ?? false,
508
+ allowForeignKeyConstraints: data.foreign_keys_enabled ?? false,
509
+ })),
510
+ ),
511
+ ),
512
+ );
513
+ }),
514
+ });
515
+
516
+ const createDatabaseName = (id: string, name: string | undefined) =>
517
+ Effect.gen(function* () {
518
+ return name ?? (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }));
519
+ });
520
+
521
+ const rootDir = Effect.sync(() => process.cwd());
@@ -0,0 +1,182 @@
1
+ import {
2
+ makeMySQLMigrationExecutor,
3
+ runMigrations,
4
+ type NormalizedMigrationsInput,
5
+ type StampedMigrationsState,
6
+ } from "@alchemy.run/sql/Migrations";
7
+ import { readSqlFile, splitSqlStatements } from "@alchemy.run/sql/SqlFile";
8
+ import * as ps from "@distilled.cloud/planetscale";
9
+ import * as Data from "effect/Data";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Redacted from "effect/Redacted";
12
+ import * as Schedule from "effect/Schedule";
13
+ import type { Connection } from "mysql2/promise";
14
+
15
+ const MIGRATION_PASSWORD_TTL_SECONDS = 600;
16
+
17
+ // `mysql2` is an optional peer dependency — loaded lazily so importing the
18
+ // Planetscale provider never requires the driver unless migrations run.
19
+ const importMysql = () =>
20
+ import("mysql2/promise").catch((cause) => {
21
+ throw new Error(
22
+ "Failed to load the 'mysql2' driver. Install the optional peer dependency 'mysql2' to run Planetscale MySQL migrations.",
23
+ { cause },
24
+ );
25
+ });
26
+
27
+ export class MySQLMigrationError extends Data.TaggedError("Planetscale::MySQLMigrationError")<{
28
+ message: string;
29
+ cause?: unknown;
30
+ }> {}
31
+
32
+ export interface MySQLMigrationTarget {
33
+ organization: string;
34
+ database: string;
35
+ branch: string;
36
+ }
37
+
38
+ /**
39
+ * PlanetScale MySQL's migration adaptation is exactly this: the shared
40
+ * pipeline with a temp-password-scoped mysql2 connection as its executor.
41
+ */
42
+ export const runMySQLMigrations = (
43
+ target: MySQLMigrationTarget,
44
+ input: NormalizedMigrationsInput,
45
+ stamped: StampedMigrationsState,
46
+ ) =>
47
+ runMigrations({
48
+ input,
49
+ stamped,
50
+ withExecutor: (apply) =>
51
+ withMySQLConnection(target, (connection) => apply(makeMySQLMigrationExecutor(connection))),
52
+ });
53
+
54
+ export const runMySQLImports = (
55
+ target: MySQLMigrationTarget,
56
+ importFiles: ReadonlyArray<string>,
57
+ rootDir: string,
58
+ previous: Record<string, string>,
59
+ ) =>
60
+ Effect.gen(function* () {
61
+ const hashes: Record<string, string> = { ...previous };
62
+ for (const filePath of importFiles) {
63
+ const file = yield* readSqlFile(rootDir, filePath);
64
+ if (previous[filePath] === file.hash) {
65
+ hashes[filePath] = file.hash;
66
+ continue;
67
+ }
68
+ yield* runMySQLSql(target, file.sql);
69
+ hashes[filePath] = file.hash;
70
+ }
71
+ const tracked = new Set(importFiles);
72
+ for (const key of Object.keys(hashes)) {
73
+ if (!tracked.has(key)) delete hashes[key];
74
+ }
75
+ return hashes;
76
+ });
77
+
78
+ const runMySQLSql = (target: MySQLMigrationTarget, sql: string) =>
79
+ withMySQLConnection(target, (connection) =>
80
+ Effect.gen(function* () {
81
+ for (const statement of splitSqlStatements(sql)) {
82
+ yield* mysqlQuery(connection, statement);
83
+ }
84
+ }),
85
+ );
86
+
87
+ const withMySQLConnection = <A, E, R>(
88
+ target: MySQLMigrationTarget,
89
+ use: (connection: Connection) => Effect.Effect<A, E, R>,
90
+ ) =>
91
+ withTemporaryMySQLPassword(target, (password) =>
92
+ Effect.acquireUseRelease(
93
+ Effect.tryPromise({
94
+ try: async () => {
95
+ const { createConnection } = await importMysql();
96
+ return createConnection({
97
+ host: password.host,
98
+ user: password.username,
99
+ password: Redacted.value(password.password),
100
+ database: target.database,
101
+ multipleStatements: true,
102
+ ssl: { rejectUnauthorized: true },
103
+ });
104
+ },
105
+ catch: toMigrationError,
106
+ }),
107
+ use,
108
+ (connection) =>
109
+ Effect.tryPromise({
110
+ try: () => connection.end(),
111
+ catch: toMigrationError,
112
+ }).pipe(Effect.ignore),
113
+ ),
114
+ );
115
+
116
+ const withTemporaryMySQLPassword = <A, E, R>(
117
+ target: MySQLMigrationTarget,
118
+ use: (password: {
119
+ id: string;
120
+ host: string;
121
+ username: string;
122
+ password: Redacted.Redacted<string>;
123
+ }) => Effect.Effect<A, E, R>,
124
+ ) =>
125
+ Effect.acquireUseRelease(
126
+ Effect.gen(function* () {
127
+ const created = yield* ps.createPassword({
128
+ organization: target.organization,
129
+ database: target.database,
130
+ branch: target.branch,
131
+ role: "admin",
132
+ ttl: MIGRATION_PASSWORD_TTL_SECONDS,
133
+ });
134
+
135
+ return {
136
+ id: created.id,
137
+ host: created.access_host_url,
138
+ username: created.username,
139
+ password: created.plain_text,
140
+ };
141
+ }),
142
+ use,
143
+ (password) =>
144
+ ps
145
+ .deletePassword({
146
+ organization: target.organization,
147
+ database: target.database,
148
+ branch: target.branch,
149
+ id: password.id,
150
+ })
151
+ .pipe(
152
+ // Already-deleted passwords are a success: nothing to clean up.
153
+ Effect.catchTag("NotFound", () => Effect.void),
154
+ Effect.retry({
155
+ schedule: Schedule.max([Schedule.exponential("500 millis"), Schedule.recurs(5)]),
156
+ }),
157
+ // Migrations succeeded; don't fail the parent over a release-step
158
+ // hiccup. The password's TTL bounds the orphan window; log loudly
159
+ // so an operator can clean it up manually if needed.
160
+ Effect.catch((cause: unknown) =>
161
+ Effect.logWarning(
162
+ `Failed to delete temporary Planetscale migration password after retries. ` +
163
+ `It will expire via TTL (~${MIGRATION_PASSWORD_TTL_SECONDS}s). ` +
164
+ `organization=${target.organization} database=${target.database} ` +
165
+ `branch=${target.branch} id=${password.id}`,
166
+ cause,
167
+ ),
168
+ ),
169
+ ),
170
+ );
171
+
172
+ const mysqlQuery = (connection: Connection, sql: string) =>
173
+ Effect.tryPromise({
174
+ try: () => connection.query(sql).then(() => undefined),
175
+ catch: toMigrationError,
176
+ });
177
+
178
+ const toMigrationError = (cause: unknown) =>
179
+ new MySQLMigrationError({
180
+ message: cause instanceof Error ? cause.message : String(cause),
181
+ cause,
182
+ });