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