1import { adopt } from "@/AdoptPolicy";
2import * as Planetscale from "@/Planetscale";
3import * as RemovalPolicy from "@/RemovalPolicy.ts";
4import * as Test from "@/Test/Alchemy";
5import * as ps from "@distilled.cloud/planetscale";
6import { describe, expect } from "alchemy-test";
7import { Data, Schedule } from "effect";
8import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import { MinimumLogLevel } from "effect/References";
12
13const { test } = Test.make({ providers: Planetscale.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20describe
21 .skipIf(!process.env.PLANETSCALE_TEST)
22 .concurrent(
23 "MySQLBranch",
24 { tags: ["provider:planetscale", "provider:planetscale:mysql", "live"] },
25 () => {
26 test.provider(
27 "adopts existing branch when adopt is true",
28 (stack) =>
29 Effect.gen(function* () {
30 const dbName = "alchemy-branch-adopt-true";
31 const branchName = "adopted";
32
33 yield* stack.destroy();
34
35 const { database } = yield* stack.deploy(
36 Effect.gen(function* () {
37 const database = yield* Planetscale.MySQLDatabase("Database", {
38 name: dbName,
39 region: { slug: "us-east" },
40 clusterSize: "PS_10",
41 });
42
43 return { database };
44 }),
45 );
46
47 yield* Planetscale.waitForBranchReady(
48 database.organization,
49 dbName,
50 "main",
51 );
52 yield* deleteBranchIfExists(
53 database.organization,
54 dbName,
55 branchName,
56 );
57
58 yield* ps.createBranch({
59 organization: database.organization,
60 database: dbName,
61 name: branchName,
62 parent_branch: "main",
63 });
64 yield* Planetscale.waitForBranchReady(
65 database.organization,
66 dbName,
67 branchName,
68 );
69
70 const { branch } = yield* stack
71 .deploy(
72 Effect.gen(function* () {
73 const database = yield* Planetscale.MySQLDatabase(
74 "Database",
75 {
76 name: dbName,
77 region: { slug: "us-east" },
78 clusterSize: "PS_10",
79 },
80 );
81 const branch = yield* Planetscale.MySQLBranch(
82 "AdoptedBranch",
83 {
84 name: branchName,
85 database,
86 parentBranch: "main",
87 isProduction: false,
88 },
89 );
90
91 return { database, branch };
92 }),
93 )
94 .pipe(adopt(true));
95
96 expect(branch).toMatchObject({
97 name: branchName,
98 organization: database.organization,
99 database: dbName,
100 parentBranch: "main",
101 production: false,
102 createdAt: expect.any(String),
103 updatedAt: expect.any(String),
104 htmlUrl: expect.any(String),
105 region: { slug: expect.any(String) },
106 });
107
108 yield* stack.destroy();
109 yield* waitForDatabaseToBeDeleted(dbName, database.organization);
110 }).pipe(logLevel),
111 5_000_000,
112 );
113
114 test.provider(
115 "errors on existing branch when adopt is false",
116 (stack) =>
117 Effect.gen(function* () {
118 const dbName = "alchemy-branch-adopt-false";
119 const branchName = "existing";
120
121 yield* stack.destroy();
122
123 const { database } = yield* stack.deploy(
124 Effect.gen(function* () {
125 const database = yield* Planetscale.MySQLDatabase("Database", {
126 name: dbName,
127 region: { slug: "us-east" },
128 clusterSize: "PS_10",
129 });
130
131 return { database };
132 }),
133 );
134
135 yield* Planetscale.waitForBranchReady(
136 database.organization,
137 dbName,
138 "main",
139 );
140 yield* deleteBranchIfExists(
141 database.organization,
142 dbName,
143 branchName,
144 );
145
146 yield* ps.createBranch({
147 organization: database.organization,
148 database: dbName,
149 name: branchName,
150 parent_branch: "main",
151 });
152 yield* Planetscale.waitForBranchReady(
153 database.organization,
154 dbName,
155 branchName,
156 );
157
158 const exit = yield* stack
159 .deploy(
160 Effect.gen(function* () {
161 const database = yield* Planetscale.MySQLDatabase(
162 "Database",
163 {
164 name: dbName,
165 region: { slug: "us-east" },
166 clusterSize: "PS_10",
167 },
168 );
169 const branch = yield* Planetscale.MySQLBranch(
170 "ExistingBranch",
171 {
172 name: branchName,
173 database,
174 parentBranch: "main",
175 isProduction: false,
176 },
177 );
178
179 return { database, branch };
180 }),
181 )
182 .pipe(Effect.exit);
183
184 yield* deleteBranchIfExists(
185 database.organization,
186 dbName,
187 branchName,
188 );
189 yield* stack.destroy();
190 yield* waitForDatabaseToBeDeleted(dbName, database.organization);
191
192 expect(Exit.isFailure(exit)).toBe(true);
193 if (Exit.isFailure(exit)) {
194 const pretty = Cause.pretty(exit.cause);
195 expect(pretty).toContain("Cannot adopt resource");
196 expect(pretty).toContain("Planetscale.MySQLBranch");
197 }
198 }).pipe(logLevel),
199 5_000_000,
200 );
201
202 test.provider(
203 "can create branch with backup",
204 (stack) =>
205 Effect.gen(function* () {
206 const dbName = "alchemy-branch-backup";
207 const branchName = "restored";
208
209 yield* stack.destroy();
210
211 const { database } = yield* stack.deploy(
212 Effect.gen(function* () {
213 const database = yield* Planetscale.MySQLDatabase("Database", {
214 name: dbName,
215 region: { slug: "us-east" },
216 clusterSize: "PS_10",
217 });
218
219 return { database };
220 }),
221 );
222
223 yield* Planetscale.waitForBranchReady(
224 database.organization,
225 dbName,
226 "main",
227 );
228 yield* deleteBranchIfExists(
229 database.organization,
230 dbName,
231 branchName,
232 );
233
234 const backup = yield* ps.createBackup({
235 organization: database.organization,
236 database: dbName,
237 branch: "main",
238 name: "alchemy-branch-backup-source",
239 retention_unit: "hour",
240 retention_value: 1,
241 });
242 yield* waitForBackupSuccess(
243 database.organization,
244 dbName,
245 "main",
246 backup.id,
247 );
248
249 const { branch } = yield* stack.deploy(
250 Effect.gen(function* () {
251 const database = yield* Planetscale.MySQLDatabase("Database", {
252 name: dbName,
253 region: { slug: "us-east" },
254 clusterSize: "PS_10",
255 });
256 const branch = yield* Planetscale.MySQLBranch(
257 "RestoredBranch",
258 {
259 name: branchName,
260 database,
261 parentBranch: "main",
262 isProduction: true,
263 backupId: backup.id,
264 clusterSize: "PS_10",
265 },
266 );
267
268 return { database, branch };
269 }),
270 );
271
272 expect(branch).toMatchObject({
273 name: branchName,
274 database: dbName,
275 parentBranch: "main",
276 production: true,
277 });
278
279 const live = yield* Planetscale.waitForBranchReady(
280 database.organization,
281 dbName,
282 branchName,
283 );
284 expect(live.name).toEqual(branchName);
285 expect(live.production).toBe(true);
286 expect(live.cluster_name).toEqual("PS_10");
287
288 yield* stack.deploy(
289 Effect.gen(function* () {
290 const database = yield* Planetscale.MySQLDatabase("Database", {
291 name: dbName,
292 region: { slug: "us-east" },
293 clusterSize: "PS_10",
294 });
295 const branch = yield* Planetscale.MySQLBranch(
296 "RestoredBranch",
297 {
298 name: branchName,
299 database,
300 parentBranch: "main",
301 isProduction: false,
302 },
303 );
304
305 return { database, branch };
306 }),
307 );
308
309 yield* stack.destroy();
310 yield* waitForDatabaseToBeDeleted(dbName, database.organization);
311 }).pipe(logLevel),
312 5_000_000,
313 );
314
315 test.provider(
316 "can enable and disable safe migrations",
317 (stack) =>
318 Effect.gen(function* () {
319 yield* stack.destroy();
320
321 const { database, branch } = yield* stack.deploy(
322 Effect.gen(function* () {
323 const database = yield* Planetscale.MySQLDatabase("Database", {
324 clusterSize: "PS_10",
325 });
326 const branch = yield* Planetscale.MySQLBranch("Branch", {
327 database,
328 parentBranch: "main",
329 isProduction: true,
330 clusterSize: "PS_10",
331 safeMigrations: true,
332 });
333
334 return { database, branch };
335 }),
336 );
337
338 const enabled = yield* ps.getBranch({
339 organization: database.organization,
340 database: database.name,
341 branch: branch.name,
342 });
343 expect(enabled.production).toBe(true);
344 expect(enabled.safe_migrations).toBe(true);
345
346 const { updatedBranch } = yield* stack.deploy(
347 Effect.gen(function* () {
348 const database = yield* Planetscale.MySQLDatabase("Database", {
349 clusterSize: "PS_10",
350 });
351 const updatedBranch = yield* Planetscale.MySQLBranch("Branch", {
352 database,
353 parentBranch: "main",
354 isProduction: true,
355 clusterSize: "PS_10",
356 safeMigrations: false,
357 });
358
359 return { database, updatedBranch };
360 }),
361 );
362
363 expect(updatedBranch.name).toEqual(branch.name);
364
365 const disabled = yield* ps.getBranch({
366 organization: database.organization,
367 database: database.name,
368 branch: branch.name,
369 });
370 expect(disabled.safe_migrations).toBe(false);
371
372 yield* stack.deploy(
373 Effect.gen(function* () {
374 const database = yield* Planetscale.MySQLDatabase("Database", {
375 clusterSize: "PS_10",
376 });
377 const branch = yield* Planetscale.MySQLBranch("Branch", {
378 database,
379 parentBranch: "main",
380 isProduction: false,
381 });
382
383 return { database, branch };
384 }),
385 );
386
387 yield* stack.destroy();
388 yield* waitForDatabaseToBeDeleted(
389 database.name,
390 database.organization,
391 );
392 }).pipe(logLevel),
393 5_000_000,
394 );
395
396 test.provider(
397 "can update cluster size",
398 (stack) =>
399 Effect.gen(function* () {
400 yield* stack.destroy();
401
402 const { database, branch } = yield* stack.deploy(
403 Effect.gen(function* () {
404 const database = yield* Planetscale.MySQLDatabase("Database", {
405 clusterSize: "PS_10",
406 });
407 const branch = yield* Planetscale.MySQLBranch("Branch", {
408 database,
409 parentBranch: "main",
410 isProduction: true,
411 clusterSize: "PS_10",
412 });
413
414 return { database, branch };
415 }),
416 );
417
418 const initial = yield* Planetscale.waitForBranchReady(
419 database.organization,
420 database.name,
421 branch.name,
422 );
423 expect(initial.cluster_name).toEqual("PS_10");
424
425 const { resizedBranch } = yield* stack.deploy(
426 Effect.gen(function* () {
427 const database = yield* Planetscale.MySQLDatabase("Database", {
428 clusterSize: "PS_10",
429 });
430 const resizedBranch = yield* Planetscale.MySQLBranch("Branch", {
431 database,
432 parentBranch: "main",
433 isProduction: true,
434 clusterSize: "PS_20",
435 });
436
437 return { database, resizedBranch };
438 }),
439 );
440
441 expect(resizedBranch.name).toEqual(branch.name);
442
443 const resized = yield* Planetscale.waitForBranchReady(
444 database.organization,
445 database.name,
446 branch.name,
447 );
448 expect(resized.cluster_name).toEqual("PS_20");
449
450 yield* stack.deploy(
451 Effect.gen(function* () {
452 const database = yield* Planetscale.MySQLDatabase("Database", {
453 clusterSize: "PS_10",
454 });
455 const branch = yield* Planetscale.MySQLBranch("Branch", {
456 database,
457 parentBranch: "main",
458 isProduction: false,
459 });
460
461 return { database, branch };
462 }),
463 );
464
465 yield* stack.destroy();
466 yield* waitForDatabaseToBeDeleted(
467 database.name,
468 database.organization,
469 );
470 }).pipe(logLevel),
471 5_000_000,
472 );
473
474 test.provider(
475 "can create branch with Branch object as parent",
476 (stack) =>
477 Effect.gen(function* () {
478 yield* stack.destroy();
479
480 const { database, parent, child } = yield* stack.deploy(
481 Effect.gen(function* () {
482 const database = yield* Planetscale.MySQLDatabase("Database", {
483 clusterSize: "PS_10",
484 });
485 const parent = yield* Planetscale.MySQLBranch("ParentBranch", {
486 database,
487 parentBranch: "main",
488 isProduction: false,
489 });
490 const child = yield* Planetscale.MySQLBranch("ChildBranch", {
491 database,
492 parentBranch: parent,
493 isProduction: false,
494 });
495
496 return { database, parent, child };
497 }),
498 );
499
500 expect(child).toMatchObject({
501 database: database.name,
502 parentBranch: parent.name,
503 production: false,
504 });
505
506 const live = yield* Planetscale.waitForBranchReady(
507 database.organization,
508 database.name,
509 child.name,
510 );
511 expect(live.parent_branch).toEqual(parent.name);
512
513 yield* stack.destroy();
514 yield* waitForDatabaseToBeDeleted(
515 database.name,
516 database.organization,
517 );
518 }).pipe(logLevel),
519 5_000_000,
520 );
521
522 test.provider(
523 "branch with RemovalPolicy.retain(true) should not be deleted via API",
524 (stack) =>
525 Effect.gen(function* () {
526 const dbName = "alchemy-branch-retain";
527 const branchName = "retained";
528
529 yield* stack.destroy();
530
531 const { database } = yield* stack.deploy(
532 Effect.gen(function* () {
533 const database = yield* Planetscale.MySQLDatabase("Database", {
534 name: dbName,
535 region: { slug: "us-east" },
536 clusterSize: "PS_10",
537 }).pipe(RemovalPolicy.retain(true));
538
539 return { database };
540 }),
541 );
542
543 yield* Planetscale.waitForBranchReady(
544 database.organization,
545 dbName,
546 "main",
547 );
548 yield* deleteBranchIfExists(
549 database.organization,
550 dbName,
551 branchName,
552 );
553
554 const { branch } = yield* stack.deploy(
555 Effect.gen(function* () {
556 const database = yield* Planetscale.MySQLDatabase("Database", {
557 name: dbName,
558 region: { slug: "us-east" },
559 clusterSize: "PS_10",
560 }).pipe(RemovalPolicy.retain(true));
561 const branch = yield* Planetscale.MySQLBranch(
562 "RetainedBranch",
563 {
564 name: branchName,
565 database,
566 parentBranch: "main",
567 isProduction: false,
568 },
569 ).pipe(RemovalPolicy.retain(true));
570
571 return { database, branch };
572 }),
573 );
574
575 expect(branch.name).toEqual(branchName);
576
577 yield* stack.destroy();
578
579 const live = yield* Planetscale.waitForBranchReady(
580 database.organization,
581 dbName,
582 branchName,
583 );
584 expect(live.name).toEqual(branchName);
585
586 yield* deleteBranchIfExists(
587 database.organization,
588 dbName,
589 branchName,
590 );
591 yield* ps
592 .deleteDatabase({
593 organization: database.organization,
594 database: dbName,
595 })
596 .pipe(Effect.catchTag("NotFound", () => Effect.void));
597 yield* waitForDatabaseToBeDeleted(dbName, database.organization);
598 }).pipe(logLevel),
599 5_000_000,
600 );
601 },
602 );
603
604const deleteBranchIfExists = Effect.fn(function* (
605 organization: string,
606 database: string,
607 branch: string,
608) {
609 yield* ps
610 .deleteBranch({ organization, database, branch })
611 .pipe(Effect.catchTag("NotFound", () => Effect.void));
612 yield* waitForBranchToBeDeleted(organization, database, branch);
613});
614
615const waitForBranchToBeDeleted = Effect.fn(function* (
616 organization: string,
617 database: string,
618 branch: string,
619) {
620 yield* ps.getBranch({ organization, database, branch }).pipe(
621 Effect.flatMap(() => Effect.fail(new BranchStillExists())),
622 Effect.retry({
623 while: (e): e is BranchStillExists => e instanceof BranchStillExists,
624 schedule: Schedule.exponential(100),
625 }),
626 Effect.catchTag("NotFound", () => Effect.void),
627 );
628});
629
630const waitForDatabaseToBeDeleted = Effect.fn(function* (
631 database: string,
632 organization: string,
633) {
634 yield* ps
635 .getDatabase({
636 organization,
637 database,
638 })
639 .pipe(
640 Effect.flatMap(() => Effect.fail(new DatabaseStillExists())),
641 Effect.retry({
642 while: (e): e is DatabaseStillExists =>
643 e instanceof DatabaseStillExists,
644 schedule: Schedule.exponential(100),
645 }),
646 Effect.catchTag("NotFound", () => Effect.void),
647 );
648});
649
650const waitForBackupSuccess = Effect.fn(function* (
651 organization: string,
652 database: string,
653 branch: string,
654 id: string,
655) {
656 return yield* ps.getBackup({ organization, database, branch, id }).pipe(
657 Effect.flatMap((backup) => {
658 switch (backup.state) {
659 case "success":
660 return Effect.succeed(backup);
661 case "failed":
662 case "canceled":
663 case "ignored":
664 return Effect.fail(
665 new BackupNotReady({ retryable: false, state: backup.state }),
666 );
667 default:
668 return Effect.fail(
669 new BackupNotReady({ retryable: true, state: backup.state }),
670 );
671 }
672 }),
673 Effect.retry({
674 while: (e): e is BackupNotReady =>
675 e instanceof BackupNotReady && e.retryable,
676 schedule: Schedule.max([
677 Schedule.exponential(1_000),
678 Schedule.recurs(120),
679 ]),
680 }),
681 );
682});
683
684class BranchStillExists extends Data.TaggedError("BranchStillExists") {}
685
686class DatabaseStillExists extends Data.TaggedError("DatabaseStillExists") {}
687
688class BackupNotReady extends Data.TaggedError("BackupNotReady")<{
689 retryable: boolean;
690 state: string;
691}> {}