1import * as Planetscale from "@/Planetscale";
2import * as Provider from "@/Provider";
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 { Redacted } from "effect";
8import * as Cause from "effect/Cause";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import { MinimumLogLevel } from "effect/References";
13import * as Schedule from "effect/Schedule";
14
15const { test } = Test.make({ providers: Planetscale.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22describe
23 .skipIf(!process.env.PLANETSCALE_TEST)
24 .concurrent(
25 "PostgresRole",
26 { tags: ["provider:planetscale", "provider:planetscale:postgres", "live"] },
27 () => {
28 // Read-only: PARENT FAN-OUT enumeration (org -> databases -> branches ->
29 // default role) against the live org, without provisioning anything.
30 test.provider("list enumerates default roles (read-only)", () =>
31 Effect.gen(function* () {
32 const provider = yield* Provider.findProvider(
33 Planetscale.PostgresDefaultRole,
34 );
35 const all = yield* provider.list();
36
37 expect(Array.isArray(all)).toBe(true);
38 for (const r of all) {
39 expect(r).toMatchObject({
40 id: expect.any(String),
41 name: expect.any(String),
42 organization: expect.any(String),
43 database: expect.any(String),
44 branch: expect.any(String),
45 privateHost: expect.any(String),
46 privateConnectionServiceName: expect.any(String),
47 });
48 }
49 }).pipe(logLevel),
50 );
51
52 // Deploy-and-find coverage, opt-in only (slow provisioning).
53 test.provider.skipIf(!process.env.PLANETSCALE_DEPLOY_TEST)(
54 "list finds a freshly deployed default role",
55 (stack) =>
56 Effect.gen(function* () {
57 yield* stack.destroy();
58
59 const { database, role } = yield* stack.deploy(
60 Effect.gen(function* () {
61 const database = yield* Planetscale.PostgresDatabase("ListDb", {
62 name: "alchemy-pg-default-list",
63 clusterSize: "PS_10",
64 arch: "arm",
65 });
66 const role = yield* Planetscale.PostgresDefaultRole(
67 "ListRole",
68 {
69 database,
70 forceReset: true,
71 },
72 );
73 return { database, role };
74 }),
75 );
76
77 const provider = yield* Provider.findProvider(
78 Planetscale.PostgresDefaultRole,
79 );
80 const all = yield* provider.list();
81
82 expect(
83 all.some(
84 (r) =>
85 r.organization === database.organization &&
86 r.database === database.name &&
87 r.branch === role.branch,
88 ),
89 ).toBe(true);
90
91 yield* stack.destroy();
92 yield* waitForDatabaseToBeDeleted(
93 database.name,
94 database.organization,
95 );
96 }).pipe(logLevel),
97 5_000_000,
98 );
99
100 // Read-only: PARENT FAN-OUT enumeration (org -> databases -> branches ->
101 // roles, excluding the default role) against the live org, without
102 // provisioning anything.
103 test.provider("list enumerates roles (read-only)", () =>
104 Effect.gen(function* () {
105 const provider = yield* Provider.findProvider(
106 Planetscale.PostgresRole,
107 );
108 const all = yield* provider.list();
109
110 expect(Array.isArray(all)).toBe(true);
111 for (const r of all) {
112 expect(r).toMatchObject({
113 id: expect.any(String),
114 name: expect.any(String),
115 organization: expect.any(String),
116 database: expect.any(String),
117 branch: expect.any(String),
118 privateHost: expect.any(String),
119 privateConnectionServiceName: expect.any(String),
120 });
121 }
122 }).pipe(logLevel),
123 );
124
125 // Deploy-and-find coverage, opt-in only (slow provisioning).
126 test.provider.skipIf(!process.env.PLANETSCALE_DEPLOY_TEST)(
127 "list finds a freshly deployed role",
128 (stack) =>
129 Effect.gen(function* () {
130 yield* stack.destroy();
131
132 const { database, role } = yield* stack.deploy(
133 Effect.gen(function* () {
134 const database = yield* Planetscale.PostgresDatabase("ListDb", {
135 name: "alchemy-pg-role-list",
136 clusterSize: "PS_10",
137 arch: "arm",
138 });
139 const role = yield* Planetscale.PostgresRole("ListRole", {
140 database,
141 inheritedRoles: ["pg_read_all_data"],
142 });
143 return { database, role };
144 }),
145 );
146
147 const provider = yield* Provider.findProvider(
148 Planetscale.PostgresRole,
149 );
150 const all = yield* provider.list();
151
152 expect(
153 all.some(
154 (r) =>
155 r.organization === database.organization &&
156 r.database === database.name &&
157 r.id === role.id,
158 ),
159 ).toBe(true);
160
161 yield* stack.destroy();
162 yield* waitForDatabaseToBeDeleted(
163 database.name,
164 database.organization,
165 );
166 }).pipe(logLevel),
167 5_000_000,
168 );
169
170 test.provider(
171 "default role - create, duplicate fails, forceReset returns new id",
172 (stack) =>
173 Effect.gen(function* () {
174 yield* stack.destroy();
175
176 // First: create default role, expect success
177 const { database, role1 } = yield* stack.deploy(
178 Effect.gen(function* () {
179 const database = yield* Planetscale.PostgresDatabase(
180 "Database",
181 {
182 clusterSize: "PS_10",
183 arch: "arm",
184 },
185 );
186 const role1 = yield* Planetscale.PostgresDefaultRole("Role1", {
187 database,
188 });
189
190 return { role1, database };
191 }),
192 );
193
194 expect(role1).toMatchObject({
195 id: expect.any(String),
196 name: expect.any(String),
197 host: expect.any(String),
198 username: expect.any(String),
199 password: expect.any(Object),
200 databaseName: "postgres",
201 branch: "main",
202 organization: database.organization,
203 privateHost: expect.any(String),
204 privateConnectionServiceName: expect.any(String),
205 });
206
207 const defaultRoleFromApi = yield* ps.getDefaultRole({
208 organization: database.organization,
209 database: database.name,
210 branch: "main",
211 });
212 expect(role1.privateConnectionServiceName).toEqual(
213 defaultRoleFromApi.private_connection_service_name,
214 );
215 expect(role1.privateHost).toEqual(
216 defaultRoleFromApi.private_access_host_url,
217 );
218
219 // Second: create again without forceReset — should fail (default already exists)
220 const exit = yield* stack
221 .deploy(
222 Effect.gen(function* () {
223 const database = yield* Planetscale.PostgresDatabase(
224 "Database",
225 {
226 clusterSize: "PS_10",
227 arch: "arm",
228 },
229 );
230 const role2 = yield* Planetscale.PostgresDefaultRole(
231 "Role2",
232 {
233 database,
234 },
235 );
236
237 return { role2 };
238 }),
239 )
240 .pipe(Effect.exit);
241
242 expect(Exit.isFailure(exit)).toBe(true);
243
244 if (Exit.isFailure(exit)) {
245 expect(Cause.pretty(exit.cause)).toMatch(
246 /Default role already exists.*Use forceReset/,
247 );
248 }
249
250 // Third: create with forceReset — should succeed and return a different role id
251 const { role3 } = yield* stack.deploy(
252 Effect.gen(function* () {
253 const database = yield* Planetscale.PostgresDatabase(
254 "Database",
255 {
256 clusterSize: "PS_10",
257 arch: "arm",
258 },
259 );
260 const role3 = yield* Planetscale.PostgresDefaultRole("Role3", {
261 database,
262 forceReset: true,
263 });
264
265 return { role3, database };
266 }),
267 );
268
269 expect(role3).toMatchObject({
270 id: expect.any(String),
271 name: expect.any(String),
272 host: expect.any(String),
273 username: expect.any(String),
274 password: expect.any(Object),
275 databaseName: "postgres",
276 branch: "main",
277 organization: database.organization,
278 });
279
280 // the default role ID is the same, but the password is different
281 expect(Redacted.value(role3.password)).not.toEqual(
282 Redacted.value(role1.password),
283 );
284
285 yield* stack.destroy();
286 yield* waitForDatabaseToBeDeleted(
287 database.name,
288 database.organization,
289 );
290 }).pipe(logLevel),
291 5_000_000,
292 );
293
294 test.provider(
295 "create and delete role",
296 (stack) =>
297 Effect.gen(function* () {
298 yield* stack.destroy();
299
300 // Create a role
301 const { database, role } = yield* stack.deploy(
302 Effect.gen(function* () {
303 const database = yield* Planetscale.PostgresDatabase(
304 "Database",
305 {
306 clusterSize: "PS_10",
307 arch: "arm",
308 },
309 );
310 const role = yield* Planetscale.PostgresRole("Role", {
311 database,
312 // Empty array means no permissions, which is fine for testing.
313 inheritedRoles: [],
314 });
315
316 return { database, role };
317 }),
318 );
319
320 expect(role).toMatchObject({
321 id: expect.any(String),
322 name: expect.any(String),
323 host: expect.any(String),
324 username: expect.any(String),
325 password: expect.any(Object),
326 privateHost: expect.any(String),
327 privateConnectionServiceName: expect.any(String),
328 });
329
330 const roleFromApi = yield* ps.getRole({
331 id: role.id,
332 database: database.name,
333 organization: database.organization,
334 branch: "main",
335 });
336 expect(role.privateConnectionServiceName).toEqual(
337 roleFromApi.private_connection_service_name,
338 );
339 expect(role.privateHost).toEqual(
340 roleFromApi.private_access_host_url,
341 );
342
343 // Update role with different ttl (should trigger replacement)
344 const { updatedRole } = yield* stack.deploy(
345 Effect.gen(function* () {
346 const database = yield* Planetscale.PostgresDatabase(
347 "Database",
348 {
349 clusterSize: "PS_10",
350 arch: "arm",
351 },
352 );
353 const updatedRole = yield* Planetscale.PostgresRole("Role", {
354 database,
355 ttl: 3600,
356 inheritedRoles: [],
357 });
358
359 return { database, updatedRole };
360 }),
361 );
362
363 expect(role.id).not.toEqual(updatedRole.id);
364 expect(updatedRole.ttl).toEqual(3600);
365
366 const found = yield* ps
367 .getRole({
368 id: role.id,
369 database: database.name,
370 organization: database.organization,
371 branch: "main",
372 })
373 .pipe(
374 Effect.map(() => true),
375 Effect.catchTag("NotFound", () => Effect.succeed(false)),
376 );
377
378 expect(found).toBe(false);
379
380 const updatedRoleFromApi = yield* ps.getRole({
381 id: updatedRole.id,
382 database: database.name,
383 organization: database.organization,
384 branch: "main",
385 });
386
387 expect(updatedRoleFromApi.ttl).toEqual(3600);
388
389 yield* stack.destroy();
390
391 yield* waitForDatabaseToBeDeleted(
392 database.name,
393 database.organization,
394 );
395 }).pipe(logLevel),
396 5_000_000,
397 );
398
399 test.provider(
400 "role with replication is created with the attribute and replaced when it changes",
401 (stack) =>
402 Effect.gen(function* () {
403 yield* stack.destroy();
404
405 const { database, role1 } = yield* stack.deploy(
406 Effect.gen(function* () {
407 const database = yield* Planetscale.PostgresDatabase(
408 "Database",
409 {
410 clusterSize: "PS_10",
411 arch: "arm",
412 },
413 );
414 const role1 = yield* Planetscale.PostgresRole(
415 "RoleReplication",
416 {
417 database,
418 inheritedRoles: ["postgres"],
419 withReplication: true,
420 },
421 );
422
423 return { database, role1 };
424 }),
425 );
426
427 expect(role1).toMatchObject({
428 id: expect.any(String),
429 inheritedRoles: ["postgres"],
430 withReplication: true,
431 });
432
433 const { role2 } = yield* stack.deploy(
434 Effect.gen(function* () {
435 const database = yield* Planetscale.PostgresDatabase(
436 "Database",
437 {
438 clusterSize: "PS_10",
439 arch: "arm",
440 },
441 );
442 const role2 = yield* Planetscale.PostgresRole(
443 "RoleReplication",
444 {
445 database,
446 inheritedRoles: ["postgres"],
447 },
448 );
449
450 return { role2 };
451 }),
452 );
453
454 expect(role2).toMatchObject({ withReplication: false });
455 expect(role2.id).not.toEqual(role1.id);
456
457 yield* stack.destroy();
458
459 yield* waitForDatabaseToBeDeleted(
460 database.name,
461 database.organization,
462 );
463 }).pipe(logLevel),
464 5_000_000,
465 );
466
467 test.provider(
468 "role gets replaced when properties change",
469 (stack) =>
470 Effect.gen(function* () {
471 yield* stack.destroy();
472
473 const { database, role1 } = yield* stack.deploy(
474 Effect.gen(function* () {
475 const database = yield* Planetscale.PostgresDatabase(
476 "Database",
477 {
478 clusterSize: "PS_10",
479 arch: "arm",
480 },
481 );
482 const role1 = yield* Planetscale.PostgresRole("RoleReplace", {
483 database: database,
484 inheritedRoles: ["pg_read_all_data"],
485 ttl: 3600,
486 });
487
488 return { database, role1 };
489 }),
490 );
491
492 expect(role1).toMatchObject({
493 id: expect.any(String),
494 name: expect.any(String),
495 inheritedRoles: ["pg_read_all_data"],
496 });
497
498 const originalId = role1.id;
499 const originalName = role1.name;
500
501 const { role2 } = yield* stack.deploy(
502 Effect.gen(function* () {
503 const database = yield* Planetscale.PostgresDatabase(
504 "Database",
505 {
506 clusterSize: "PS_10",
507 arch: "arm",
508 },
509 );
510 const role2 = yield* Planetscale.PostgresRole("RoleReplace", {
511 database: database,
512 inheritedRoles: ["postgres"],
513 ttl: 7200,
514 });
515
516 return { role2 };
517 }),
518 );
519
520 expect(role2).toMatchObject({
521 id: expect.any(String),
522 name: expect.any(String),
523 inheritedRoles: ["postgres"],
524 });
525
526 expect(role2.id).not.toEqual(originalId);
527 expect(role2.name).not.toEqual(originalName);
528
529 yield* stack.destroy();
530
531 yield* waitForDatabaseToBeDeleted(
532 database.name,
533 database.organization,
534 );
535 }).pipe(logLevel),
536 5_000_000,
537 );
538
539 test.provider(
540 "role with RemovalPolicy.retain(true) should not be deleted via API",
541 (stack) =>
542 Effect.gen(function* () {
543 yield* stack.destroy();
544
545 const { organization } = yield* yield* Planetscale.Credentials;
546
547 const { database, role } = yield* stack.deploy(
548 Effect.gen(function* () {
549 const database = yield* Planetscale.PostgresDatabase(
550 "Database",
551 {
552 clusterSize: "PS_10",
553 arch: "arm",
554 },
555 ).pipe(RemovalPolicy.retain(true));
556 const role = yield* Planetscale.PostgresRole(
557 "RoleRetainRemoval",
558 {
559 database,
560 inheritedRoles: ["postgres"],
561 },
562 ).pipe(RemovalPolicy.retain(true));
563
564 return { database, role };
565 }),
566 );
567
568 expect(role).toMatchObject({
569 id: expect.any(String),
570 name: expect.any(String),
571 database: database.name,
572 inheritedRoles: ["postgres"],
573 });
574
575 yield* stack.destroy();
576
577 const liveRole = yield* ps
578 .getRole({
579 organization,
580 database: database.name,
581 branch: "main",
582 id: role.id,
583 })
584 .pipe(
585 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
586 );
587
588 expect(liveRole).toBeDefined();
589 expect(liveRole?.id).toEqual(role.id);
590
591 // deleting the db takes care of deleting the role
592 yield* ps
593 .deleteDatabase({
594 organization,
595 database: database.name,
596 })
597 .pipe(Effect.catchTag("NotFound", () => Effect.void));
598
599 yield* waitForDatabaseToBeDeleted(
600 database.name,
601 database.organization,
602 );
603 }).pipe(logLevel),
604 5_000_000,
605 );
606
607 test.provider(
608 "role update: successor is updatable without replacement",
609 (stack) =>
610 Effect.gen(function* () {
611 yield* stack.destroy();
612
613 const { database, role1 } = yield* stack.deploy(
614 Effect.gen(function* () {
615 const database = yield* Planetscale.PostgresDatabase(
616 "Database",
617 {
618 clusterSize: "PS_10",
619 arch: "arm",
620 },
621 );
622 const role1 = yield* Planetscale.PostgresRole("RoleSuccessor", {
623 database,
624 inheritedRoles: ["postgres"],
625 successor: "postgres",
626 });
627
628 return { database, role1 };
629 }),
630 );
631
632 expect(role1).toMatchObject({
633 id: expect.any(String),
634 name: expect.any(String),
635 successor: "postgres",
636 });
637
638 const originalId = role1.id;
639
640 const { role2 } = yield* stack.deploy(
641 Effect.gen(function* () {
642 const database = yield* Planetscale.PostgresDatabase(
643 "Database",
644 {
645 clusterSize: "PS_10",
646 arch: "arm",
647 },
648 );
649 const role2 = yield* Planetscale.PostgresRole("RoleSuccessor", {
650 database,
651 inheritedRoles: ["postgres"],
652 successor: "pg_read_all_data",
653 });
654
655 return { role2 };
656 }),
657 );
658
659 expect(role2).toMatchObject({
660 id: originalId,
661 successor: "pg_read_all_data",
662 });
663
664 expect(role2.id).toEqual(originalId);
665
666 yield* stack.destroy();
667
668 yield* waitForDatabaseToBeDeleted(
669 database.name,
670 database.organization,
671 );
672 }).pipe(logLevel),
673 5_000_000,
674 );
675
676 test.provider(
677 "role with custom branch",
678 (stack) =>
679 Effect.gen(function* () {
680 yield* stack.destroy();
681
682 const { database, branch, role } = yield* stack.deploy(
683 Effect.gen(function* () {
684 const database = yield* Planetscale.PostgresDatabase(
685 "Database",
686 {
687 clusterSize: "PS_10",
688 arch: "arm",
689 },
690 );
691
692 const branch = yield* Planetscale.PostgresBranch(
693 "CustomBranch",
694 {
695 database,
696 },
697 );
698
699 const role = yield* Planetscale.PostgresRole(
700 "RoleCustomBranch",
701 {
702 database,
703 branch,
704 inheritedRoles: ["postgres"],
705 },
706 );
707
708 return { database, branch, role };
709 }),
710 );
711
712 expect(role).toMatchObject({
713 id: expect.any(String),
714 name: expect.any(String),
715 database: database.name,
716 branch: branch.name,
717 inheritedRoles: ["postgres"],
718 });
719
720 yield* stack.destroy();
721
722 yield* waitForDatabaseToBeDeleted(
723 database.name,
724 database.organization,
725 );
726 }).pipe(logLevel),
727 5_000_000,
728 );
729 },
730 );
731const waitForDatabaseToBeDeleted = Effect.fn(function* (
732 database: string,
733 organization: string,
734) {
735 yield* ps
736 .getDatabase({
737 organization,
738 database,
739 })
740 .pipe(
741 Effect.flatMap(() => Effect.fail(new DatabaseStillExists())),
742 Effect.retry({
743 while: (e): e is DatabaseStillExists =>
744 e instanceof DatabaseStillExists,
745 schedule: Schedule.exponential(100),
746 }),
747 Effect.catchTag("NotFound", () => Effect.void),
748 );
749});
750
751class DatabaseStillExists extends Data.TaggedError("DatabaseStillExists") {}