1import { adopt } from "@/AdoptPolicy";
2import * as Planetscale from "@/Planetscale";
3import * as Provider from "@/Provider";
4import * as RemovalPolicy from "@/RemovalPolicy.ts";
5import * as Test from "@/Test/Alchemy";
6import * as ps from "@distilled.cloud/planetscale";
7import { describe, expect } from "alchemy-test";
8import { Data, Schedule } from "effect";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import { MinimumLogLevel } from "effect/References";
13
14const { test } = Test.make({ providers: Planetscale.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const fixturesDir = `${import.meta.dirname}/fixtures`;
22
23describe
24 .skipIf(!process.env.PLANETSCALE_TEST)
25 .concurrent(
26 "MySQLDatabase",
27 { tags: ["provider:planetscale", "provider:planetscale:mysql", "live"] },
28 () => {
29 // Read-only: exercises the real org-wide enumeration code path without
30 // provisioning anything (PlanetScale provisioning is extremely slow).
31 test.provider("list enumerates databases (read-only)", () =>
32 Effect.gen(function* () {
33 const provider = yield* Provider.findProvider(
34 Planetscale.MySQLDatabase,
35 );
36 const all = yield* provider.list();
37
38 expect(Array.isArray(all)).toBe(true);
39 for (const db of all) {
40 expect(db).toMatchObject({
41 id: expect.any(String),
42 name: expect.any(String),
43 organization: expect.any(String),
44 region: { slug: expect.any(String) },
45 });
46 }
47 }).pipe(logLevel),
48 );
49
50 // Deploy-and-find coverage, opt-in only (slow provisioning).
51 test.provider.skipIf(!process.env.PLANETSCALE_DEPLOY_TEST)(
52 "list finds a freshly deployed database",
53 (stack) =>
54 Effect.gen(function* () {
55 yield* stack.destroy();
56
57 const { database } = yield* stack.deploy(
58 Effect.gen(function* () {
59 const database = yield* Planetscale.MySQLDatabase(
60 "ListDatabase",
61 {
62 name: "alchemy-mysql-db-list",
63 clusterSize: "PS_10",
64 },
65 );
66 return { database };
67 }),
68 );
69
70 const provider = yield* Provider.findProvider(
71 Planetscale.MySQLDatabase,
72 );
73 const all = yield* provider.list();
74
75 expect(
76 all.some(
77 (db) =>
78 db.organization === database.organization &&
79 db.name === database.name,
80 ),
81 ).toBe(true);
82
83 yield* stack.destroy();
84 }).pipe(logLevel),
85 5_000_000,
86 );
87
88 test.provider("create database with minimal settings", (stack) =>
89 Effect.gen(function* () {
90 yield* stack.destroy();
91
92 const { database } = yield* stack.deploy(
93 Effect.gen(function* () {
94 const database = yield* Planetscale.MySQLDatabase(
95 "MySQLDatabaseBasic",
96 {
97 clusterSize: "PS_10",
98 },
99 );
100
101 return {
102 database,
103 };
104 }),
105 );
106
107 expect(database).toMatchObject({
108 id: expect.any(String),
109 name: expect.any(String),
110 organization: expect.any(String),
111 state: expect.any(String),
112 defaultBranch: "main",
113 plan: expect.any(String),
114 createdAt: expect.any(String),
115 updatedAt: expect.any(String),
116 htmlUrl: expect.any(String),
117 region: {
118 slug: expect.any(String),
119 },
120 clusterSize: "PS_10",
121 });
122
123 const branch = yield* Planetscale.waitForBranchReady(
124 database.organization,
125 database.name,
126 "main",
127 );
128
129 expect(branch.cluster_name).toEqual("PS_10");
130
131 yield* stack.destroy();
132 }).pipe(logLevel),
133 );
134
135 test.provider("create, update, and delete database", (stack) =>
136 Effect.gen(function* () {
137 yield* stack.destroy();
138
139 const { database } = yield* stack.deploy(
140 Effect.gen(function* () {
141 const database = yield* Planetscale.MySQLDatabase(
142 "MySQLDatabaseCRUD",
143 {
144 region: {
145 slug: "us-east",
146 },
147 clusterSize: "PS_10",
148 defaultBranch: "main",
149 allowDataBranching: true,
150 automaticMigrations: true,
151 requireApprovalForDeploy: false,
152 restrictBranchRegion: true,
153 insightsRawQueries: true,
154 productionBranchWebConsole: true,
155 migrationFramework: "rails",
156 migrationTableName: "schema_migrations",
157 },
158 );
159
160 return {
161 database,
162 };
163 }),
164 );
165
166 expect(database).toMatchObject({
167 id: expect.any(String),
168 name: expect.any(String),
169 organization: expect.any(String),
170 state: expect.any(String),
171 plan: expect.any(String),
172 createdAt: expect.any(String),
173 updatedAt: expect.any(String),
174 htmlUrl: expect.any(String),
175 region: {
176 slug: expect.any(String),
177 },
178 clusterSize: "PS_10",
179 defaultBranch: "main",
180 allowDataBranching: true,
181 automaticMigrations: true,
182 requireApprovalForDeploy: false,
183 restrictBranchRegion: true,
184 insightsRawQueries: true,
185 productionBranchWebConsole: true,
186 migrationFramework: "rails",
187 migrationTableName: "schema_migrations",
188 });
189
190 const { updatedDatabase } = yield* stack.deploy(
191 Effect.gen(function* () {
192 const updatedDatabase = yield* Planetscale.MySQLDatabase(
193 "MySQLDatabaseCRUD",
194 {
195 clusterSize: "PS_20",
196 allowDataBranching: false,
197 automaticMigrations: true,
198 requireApprovalForDeploy: true,
199 restrictBranchRegion: false,
200 insightsRawQueries: false,
201 productionBranchWebConsole: false,
202 defaultBranch: "main",
203 migrationFramework: "django",
204 migrationTableName: "django_migrations",
205 },
206 );
207
208 return {
209 updatedDatabase,
210 };
211 }),
212 );
213
214 expect(updatedDatabase).toMatchObject({
215 allowDataBranching: false,
216 automaticMigrations: true,
217 requireApprovalForDeploy: true,
218 restrictBranchRegion: false,
219 insightsRawQueries: false,
220 productionBranchWebConsole: false,
221 defaultBranch: "main",
222 migrationFramework: "django",
223 migrationTableName: "django_migrations",
224 });
225
226 const branch = yield* Planetscale.waitForBranchReady(
227 database.organization,
228 database.name,
229 "main",
230 );
231
232 expect(branch.cluster_name).toEqual("PS_20");
233
234 yield* stack.destroy();
235
236 yield* waitForDatabaseToBeDeleted(
237 database.name,
238 database.organization,
239 );
240 }).pipe(logLevel),
241 );
242
243 test.provider(
244 "reconciles replicas in place via keyspace resize",
245 (stack) =>
246 Effect.gen(function* () {
247 yield* stack.destroy();
248
249 const { database } = yield* stack.deploy(
250 Effect.gen(function* () {
251 const database = yield* Planetscale.MySQLDatabase(
252 "MySQLDatabaseReplicas",
253 {
254 clusterSize: "PS_10",
255 replicas: 3,
256 },
257 );
258 return { database };
259 }),
260 );
261
262 // PS_10 includes 2 replicas; the third is added in place via a
263 // keyspace resize request after creation.
264 expect(database.replicas).toEqual(3);
265
266 const keyspaces = yield* ps.listKeyspaces({
267 organization: database.organization,
268 database: database.name,
269 branch: "main",
270 });
271 const keyspace = keyspaces.data.find(
272 (x) => x.name === database.name,
273 );
274 expect(keyspace?.replicas).toEqual(3);
275 expect(keyspace?.extra_replicas).toEqual(1);
276
277 // Scale back down — must resize in place, never replace.
278 const { updatedDatabase } = yield* stack.deploy(
279 Effect.gen(function* () {
280 const updatedDatabase = yield* Planetscale.MySQLDatabase(
281 "MySQLDatabaseReplicas",
282 {
283 clusterSize: "PS_10",
284 replicas: 2,
285 },
286 );
287 return { updatedDatabase };
288 }),
289 );
290
291 expect(updatedDatabase.id).toEqual(database.id);
292 expect(updatedDatabase.replicas).toEqual(2);
293
294 yield* stack.destroy();
295
296 yield* waitForDatabaseToBeDeleted(
297 database.name,
298 database.organization,
299 );
300 }).pipe(logLevel),
301 5_000_000,
302 );
303
304 test.provider(
305 "creates non-main default branch if specified",
306 (stack) =>
307 Effect.gen(function* () {
308 yield* stack.destroy();
309
310 const { database } = yield* stack.deploy(
311 Effect.gen(function* () {
312 const database = yield* Planetscale.MySQLDatabase(
313 "MySQLDatabaseCustomBranch",
314 {
315 clusterSize: "PS_10",
316 defaultBranch: "custom",
317 },
318 );
319
320 return {
321 database,
322 };
323 }),
324 );
325
326 expect(database).toMatchObject({
327 defaultBranch: "custom",
328 });
329
330 const branch = yield* Planetscale.waitForBranchReady(
331 database.organization,
332 database.name,
333 "custom",
334 );
335
336 expect(branch.name).toEqual("custom");
337 expect(branch.parent_branch).toEqual("main");
338 expect(branch.cluster_name).toEqual("PS_10");
339
340 yield* stack.destroy();
341
342 yield* waitForDatabaseToBeDeleted(
343 database.name,
344 database.organization,
345 );
346 }).pipe(logLevel),
347 5_000_000, // must wait on multiple resizes and branch creation
348 );
349
350 test.provider(
351 "applies migrations and import files",
352 (stack) =>
353 Effect.gen(function* () {
354 yield* stack.destroy();
355
356 const importFile = `${fixturesDir}/seed.sql`;
357 const { database } = yield* stack.deploy(
358 Effect.gen(function* () {
359 const database = yield* Planetscale.MySQLDatabase(
360 "MySQLDatabaseMigrations",
361 {
362 clusterSize: "PS_10",
363 migrations: `${fixturesDir}/migrations`,
364 importFiles: [importFile],
365 },
366 );
367
368 return {
369 database,
370 };
371 }),
372 );
373
374 expect(database.migrationsTable).toEqual("__alchemy_migrations");
375 expect(
376 database.migrationsHashes["0001_create_widgets.sql"],
377 ).toEqual(expect.any(String));
378 expect(database.importHashes[importFile]).toEqual(
379 expect.any(String),
380 );
381
382 yield* stack.destroy();
383
384 yield* waitForDatabaseToBeDeleted(
385 database.name,
386 database.organization,
387 );
388 }).pipe(logLevel),
389 5_000_000,
390 );
391
392 test.provider(
393 "adopt with wrong kind should throw",
394 (stack) =>
395 Effect.gen(function* () {
396 yield* stack.destroy();
397
398 const { database } = yield* stack.deploy(
399 Effect.gen(function* () {
400 const database = yield* Planetscale.PostgresDatabase(
401 "PgBaselineWrongKind",
402 {
403 region: { slug: "us-east" },
404 clusterSize: "PS_10",
405 arch: "arm", // arm is slightly faster
406 },
407 );
408
409 return { database };
410 }),
411 );
412
413 yield* Planetscale.waitForDatabaseReady(
414 database.organization,
415 database.name,
416 );
417 const exit = yield* Effect.exit(
418 stack
419 .deploy(
420 Effect.gen(function* () {
421 return yield* Planetscale.MySQLDatabase("MysqlWrongKind", {
422 name: database.name,
423 clusterSize: "PS_10",
424 });
425 }),
426 )
427 .pipe(adopt(true)),
428 );
429
430 expect(Exit.isFailure(exit)).toBe(true);
431
432 if (Exit.isFailure(exit)) {
433 const pretty = Cause.pretty(exit.cause);
434
435 expect(pretty).toContain("postgresql");
436 expect(pretty).toContain("PostgresDatabase");
437 }
438 yield* stack.destroy();
439 }).pipe(logLevel),
440 { timeout: 5_000_000, tags: ["provider:planetscale:postgres"] },
441 );
442
443 test.provider(
444 "database with RemovalPolicy.retain(true) should not be deleted via API",
445 (stack) =>
446 Effect.gen(function* () {
447 yield* stack.destroy();
448
449 const { database } = yield* stack.deploy(
450 Effect.gen(function* () {
451 const database = yield* Planetscale.MySQLDatabase(
452 "MySQLDatabaseRetainRemoval",
453 {
454 region: { slug: "us-east" },
455 clusterSize: "PS_10",
456 },
457 ).pipe(RemovalPolicy.retain(true));
458
459 return {
460 database,
461 };
462 }),
463 );
464
465 // Verify database exists
466 const readyDatabase = yield* Planetscale.waitForDatabaseReady(
467 database.organization,
468 database.name,
469 );
470
471 expect(readyDatabase.name).toEqual(database.name);
472
473 // When we call destroy, the database should NOT be deleted via API
474 yield* stack.destroy();
475
476 yield* Planetscale.waitForDatabaseReady(
477 database.organization,
478 database.name,
479 );
480
481 // Verify database still exists (was not deleted via API)
482 const live = yield* ps.getDatabase({
483 organization: database.organization,
484 database: database.name,
485 });
486
487 // Database should still exist
488 expect(live.name).toEqual(database.name);
489 expect(live.state).toEqual("ready");
490 expect(live.kind).toEqual("mysql");
491
492 // Clean up manually for the test
493 yield* ps
494 .deleteDatabase({
495 organization: database.organization,
496 database: database.name,
497 })
498 .pipe(Effect.catchTag("NotFound", () => Effect.void));
499 }).pipe(logLevel),
500 5_000_000,
501 );
502 },
503 );
504
505const waitForDatabaseToBeDeleted = Effect.fn(function* (
506 database: string,
507 organization: string,
508) {
509 yield* ps
510 .getDatabase({
511 organization,
512 database,
513 })
514 .pipe(
515 Effect.flatMap(() => Effect.fail(new DatabaseStillExists())),
516 Effect.retry({
517 while: (e): e is DatabaseStillExists =>
518 e instanceof DatabaseStillExists,
519 schedule: Schedule.exponential(100),
520 }),
521 Effect.catchTag("NotFound", () => Effect.void),
522 );
523});
524
525class DatabaseStillExists extends Data.TaggedError("DatabaseStillExists") {}