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 "PostgresDatabase",
27 { tags: ["provider:planetscale", "provider:planetscale:postgres", "live"] },
28 () => {
29 // Read-only: exercises the real org-wide enumeration code path (with the
30 // per-database default-branch hydration) without provisioning anything.
31 test.provider("list enumerates databases (read-only)", () =>
32 Effect.gen(function* () {
33 const provider = yield* Provider.findProvider(
34 Planetscale.PostgresDatabase,
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 arch: expect.stringMatching(/^(x86|arm)$/),
46 });
47 }
48 }).pipe(logLevel),
49 );
50
51 // Deploy-and-find coverage, opt-in only (slow provisioning).
52 test.provider.skipIf(!process.env.PLANETSCALE_DEPLOY_TEST)(
53 "list finds a freshly deployed database",
54 (stack) =>
55 Effect.gen(function* () {
56 yield* stack.destroy();
57
58 const { database } = yield* stack.deploy(
59 Effect.gen(function* () {
60 const database = yield* Planetscale.PostgresDatabase(
61 "ListDatabase",
62 {
63 name: "alchemy-pg-db-list",
64 clusterSize: "PS_10",
65 },
66 );
67 return { database };
68 }),
69 );
70
71 const provider = yield* Provider.findProvider(
72 Planetscale.PostgresDatabase,
73 );
74 const all = yield* provider.list();
75
76 expect(
77 all.some(
78 (db) =>
79 db.organization === database.organization &&
80 db.name === database.name,
81 ),
82 ).toBe(true);
83
84 yield* stack.destroy();
85 }).pipe(logLevel),
86 5_000_000,
87 );
88
89 test.provider(
90 "create database with minimal settings",
91 (stack) =>
92 Effect.gen(function* () {
93 yield* stack.destroy();
94
95 const { database } = yield* stack.deploy(
96 Effect.gen(function* () {
97 const database = yield* Planetscale.PostgresDatabase(
98 "PostgresDatabaseBasic",
99 {
100 clusterSize: "PS_10",
101 },
102 );
103
104 return {
105 database,
106 };
107 }),
108 );
109
110 expect(database).toMatchObject({
111 id: expect.any(String),
112 name: expect.any(String),
113 organization: expect.any(String),
114 state: expect.any(String),
115 defaultBranch: "main",
116 plan: expect.any(String),
117 createdAt: expect.any(String),
118 updatedAt: expect.any(String),
119 htmlUrl: expect.any(String),
120 region: {
121 slug: expect.any(String),
122 },
123 clusterSize: "PS_10_AWS_X86",
124 });
125
126 const branch = yield* Planetscale.waitForBranchReady(
127 database.organization,
128 database.name,
129 "main",
130 );
131
132 expect(branch.cluster_name).toEqual("PS_10_AWS_X86");
133
134 yield* stack.destroy();
135 }).pipe(logLevel),
136 5_000_000,
137 );
138
139 test.provider(
140 "create database with minimal settings and arm arch",
141 (stack) =>
142 Effect.gen(function* () {
143 yield* stack.destroy();
144
145 const { database } = yield* stack.deploy(
146 Effect.gen(function* () {
147 const database = yield* Planetscale.PostgresDatabase(
148 "PostgresDatabaseBasicArm",
149 {
150 clusterSize: "PS_10",
151 arch: "arm",
152 },
153 );
154
155 return {
156 database,
157 };
158 }),
159 );
160
161 expect(database).toMatchObject({
162 id: expect.any(String),
163 name: expect.any(String),
164 organization: expect.any(String),
165 state: expect.any(String),
166 defaultBranch: "main",
167 plan: expect.any(String),
168 createdAt: expect.any(String),
169 updatedAt: expect.any(String),
170 htmlUrl: expect.any(String),
171 region: {
172 slug: expect.any(String),
173 },
174 arch: "arm",
175 clusterSize: "PS_10_AWS_ARM",
176 });
177
178 const branch = yield* Planetscale.waitForBranchReady(
179 database.organization,
180 database.name,
181 "main",
182 );
183
184 expect(branch.cluster_name).toEqual("PS_10_AWS_ARM");
185
186 yield* stack.destroy();
187 }).pipe(logLevel),
188 5_000_000,
189 );
190
191 test.provider(
192 "create, update, and delete database",
193 (stack) =>
194 Effect.gen(function* () {
195 const name = `alchemy-test-postgresql-crud`;
196
197 yield* stack.destroy();
198
199 const { database } = yield* stack.deploy(
200 Effect.gen(function* () {
201 const database = yield* Planetscale.PostgresDatabase(
202 "PostgresDatabaseCRUD",
203 {
204 name,
205 region: {
206 slug: "us-east",
207 },
208 clusterSize: "PS_10",
209 defaultBranch: "main",
210 requireApprovalForDeploy: false,
211 restrictBranchRegion: true,
212 productionBranchWebConsole: true,
213 },
214 );
215
216 return {
217 database,
218 };
219 }),
220 );
221
222 expect(database).toMatchObject({
223 id: expect.any(String),
224 name,
225 organization: expect.any(String),
226 state: expect.any(String),
227 plan: expect.any(String),
228 createdAt: expect.any(String),
229 updatedAt: expect.any(String),
230 htmlUrl: expect.any(String),
231 region: {
232 slug: expect.any(String),
233 },
234 clusterSize: "PS_10_AWS_X86",
235 defaultBranch: "main",
236 requireApprovalForDeploy: false,
237 restrictBranchRegion: true,
238 productionBranchWebConsole: true,
239 });
240
241 const { updatedDatabase } = yield* stack.deploy(
242 Effect.gen(function* () {
243 const updatedDatabase = yield* Planetscale.PostgresDatabase(
244 "PostgresDatabaseCRUD",
245 {
246 name,
247 clusterSize: "PS_20",
248 requireApprovalForDeploy: true,
249 restrictBranchRegion: false,
250 productionBranchWebConsole: false,
251 defaultBranch: "main",
252 },
253 );
254
255 return {
256 updatedDatabase,
257 };
258 }),
259 );
260
261 expect(updatedDatabase).toMatchObject({
262 name,
263 requireApprovalForDeploy: true,
264 restrictBranchRegion: false,
265 productionBranchWebConsole: false,
266 defaultBranch: "main",
267 });
268
269 const branch = yield* Planetscale.waitForBranchReady(
270 database.organization,
271 database.name,
272 "main",
273 );
274
275 expect(branch.cluster_name).toEqual("PS_20_AWS_X86");
276
277 yield* stack.destroy();
278
279 yield* waitForDatabaseToBeDeleted(
280 database.name,
281 database.organization,
282 );
283 }).pipe(logLevel),
284 5_000_000,
285 );
286
287 test.provider(
288 "creates non-main default branch if specified",
289 (stack) =>
290 Effect.gen(function* () {
291 const name = `alchemy-test-postgresql-custom-branch`;
292 const defaultBranch = "custom";
293
294 yield* stack.destroy();
295
296 const { database } = yield* stack.deploy(
297 Effect.gen(function* () {
298 const database = yield* Planetscale.PostgresDatabase(
299 "PostgresDatabaseCustomBranch",
300 {
301 name,
302 clusterSize: "PS_10",
303 defaultBranch,
304 },
305 );
306
307 return {
308 database,
309 };
310 }),
311 );
312
313 expect(database).toMatchObject({
314 name,
315 defaultBranch,
316 });
317
318 const branch = yield* Planetscale.waitForBranchReady(
319 database.organization,
320 database.name,
321 defaultBranch,
322 );
323
324 expect(branch.name).toEqual(defaultBranch);
325 expect(branch.parent_branch).toEqual("main");
326 expect(branch.cluster_name).toEqual("PS_10_AWS_X86");
327
328 yield* stack.destroy();
329
330 yield* waitForDatabaseToBeDeleted(
331 database.name,
332 database.organization,
333 );
334 }).pipe(logLevel),
335 5_000_000, // must wait on multiple resizes and branch creation
336 );
337
338 test.provider(
339 "applies migrations and import files",
340 (stack) =>
341 Effect.gen(function* () {
342 yield* stack.destroy();
343
344 const importFile = `${fixturesDir}/seed.sql`;
345 const { database } = yield* stack.deploy(
346 Effect.gen(function* () {
347 const database = yield* Planetscale.PostgresDatabase(
348 "PostgresDatabaseMigrations",
349 {
350 clusterSize: "PS_10",
351 migrations: `${fixturesDir}/migrations`,
352 importFiles: [importFile],
353 },
354 );
355
356 return {
357 database,
358 };
359 }),
360 );
361
362 expect(database.migrationsTable).toEqual("__alchemy_migrations");
363 expect(
364 database.migrationsHashes["0001_create_widgets.sql"],
365 ).toEqual(expect.any(String));
366 expect(database.importHashes[importFile]).toEqual(
367 expect.any(String),
368 );
369
370 yield* stack.destroy();
371
372 yield* waitForDatabaseToBeDeleted(
373 database.name,
374 database.organization,
375 );
376 }).pipe(logLevel),
377 5_000_000,
378 );
379
380 test.provider(
381 "adopt with wrong kind should throw",
382 (stack) =>
383 Effect.gen(function* () {
384 const name = `alchemy-test-postgresql-kind`;
385
386 yield* stack.destroy();
387
388 const { database } = yield* stack.deploy(
389 Effect.gen(function* () {
390 const database = yield* Planetscale.MySQLDatabase(
391 "MySQLBaselineWrongKind",
392 {
393 name,
394 region: { slug: "us-east" },
395 clusterSize: "PS_10",
396 },
397 );
398
399 return { database };
400 }),
401 );
402
403 yield* Planetscale.waitForDatabaseReady(
404 database.organization,
405 name,
406 );
407
408 const exit = yield* Effect.exit(
409 stack
410 .deploy(
411 Effect.gen(function* () {
412 const database = yield* Planetscale.PostgresDatabase(
413 "PostgresWrongKind",
414 {
415 name,
416 clusterSize: "PS_10",
417 },
418 );
419
420 return { database };
421 }),
422 )
423 .pipe(adopt(true)),
424 );
425
426 expect(Exit.isFailure(exit)).toBe(true);
427
428 if (Exit.isFailure(exit)) {
429 const pretty = Cause.pretty(exit.cause);
430 expect(pretty).toContain("mysql");
431 expect(pretty).toContain("MySQLDatabase");
432 }
433
434 yield* stack.destroy();
435 }).pipe(logLevel),
436 { timeout: 5_000_000, tags: ["provider:planetscale:mysql"] },
437 );
438
439 test.provider(
440 "adopt with wrong arch should trigger replace",
441 (stack) =>
442 Effect.gen(function* () {
443 yield* stack.destroy();
444
445 // Create a database with arm
446 const { database } = yield* stack.deploy(
447 Effect.gen(function* () {
448 const database = yield* Planetscale.PostgresDatabase(
449 "PostgresDatabaseWrongArch",
450 {
451 arch: "arm",
452 clusterSize: "PS_10",
453 },
454 );
455
456 return {
457 database,
458 };
459 }),
460 );
461
462 expect(database.arch).toEqual("arm");
463
464 // Now try to adopt it with a different arch — should replace
465 const { newDatabase } = yield* stack.deploy(
466 Effect.gen(function* () {
467 const newDatabase = yield* Planetscale.PostgresDatabase(
468 "PostgresDatabaseWrongArch",
469 {
470 arch: "x86",
471 clusterSize: "PS_10",
472 },
473 );
474 return {
475 newDatabase,
476 };
477 }),
478 );
479
480 expect(newDatabase.id).not.toBe(database.id);
481
482 yield* stack.destroy();
483
484 yield* waitForDatabaseToBeDeleted(
485 database.name,
486 database.organization,
487 );
488 yield* waitForDatabaseToBeDeleted(
489 newDatabase.name,
490 newDatabase.organization,
491 );
492 }).pipe(logLevel),
493 5_000_000,
494 );
495
496 test.provider(
497 "database with RemovalPolicy.retain(true) should not be deleted via API",
498 (stack) =>
499 Effect.gen(function* () {
500 yield* stack.destroy();
501
502 const { database } = yield* stack.deploy(
503 Effect.gen(function* () {
504 const database = yield* Planetscale.PostgresDatabase(
505 "PostgresDatabaseRetainRemoval",
506 {
507 region: { slug: "us-east" },
508 clusterSize: "PS_10",
509 },
510 ).pipe(RemovalPolicy.retain(true));
511
512 return {
513 database,
514 };
515 }),
516 );
517
518 // Verify database exists
519 yield* Planetscale.waitForDatabaseReady(
520 database.organization,
521 database.name,
522 );
523
524 // When we call destroy, the database should NOT be deleted via API
525 yield* stack.destroy();
526
527 yield* Planetscale.waitForDatabaseReady(
528 database.organization,
529 database.name,
530 );
531
532 // Verify database still exists (was not deleted via API)
533 const live = yield* ps.getDatabase({
534 organization: database.organization,
535 database: database.name,
536 });
537
538 // Database should still exist
539 expect(live.name).toEqual(database.name);
540 expect(live.state).toEqual("ready");
541 expect(live.kind).toEqual("postgresql");
542
543 // Clean up manually for the test
544 yield* ps
545 .deleteDatabase({
546 organization: database.organization,
547 database: database.name,
548 })
549 .pipe(Effect.catchTag("NotFound", () => Effect.void));
550 }).pipe(logLevel),
551 5_000_000,
552 );
553 },
554 );
555
556const waitForDatabaseToBeDeleted = Effect.fn(function* (
557 database: string,
558 organization: string,
559) {
560 yield* ps
561 .getDatabase({
562 organization,
563 database,
564 })
565 .pipe(
566 Effect.flatMap(() => Effect.fail(new DatabaseStillExists())),
567 Effect.retry({
568 while: (e): e is DatabaseStillExists =>
569 e instanceof DatabaseStillExists,
570 schedule: Schedule.exponential(100),
571 }),
572 Effect.catchTag("NotFound", () => Effect.void),
573 );
574});
575
576class DatabaseStillExists extends Data.TaggedError("DatabaseStillExists") {}