1import { Credentials } from "@distilled.cloud/planetscale/Credentials";
2import * as planetscale from "@distilled.cloud/planetscale";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6import { deepEqual, isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 isKnownError,
13 PlanetscaleConflict,
14 waitForBranchReady,
15} from "../Util.ts";
16import type { PostgresBranch } from "./PostgresBranch.ts";
17import type { PostgresDatabase } from "./PostgresDatabase.ts";
18import type { PostgresOrigin } from "./PostgresOrigin.ts";
19
20/**
21 * Built-in PostgreSQL roles that can be inherited from. Custom role names
22 * (referencing other Role resources) are also accepted.
23 */
24export type InheritedRole =
25 | "pscale_managed"
26 | "pg_checkpoint"
27 | "pg_create_subscription"
28 | "pg_maintain"
29 | "pg_monitor"
30 | "pg_read_all_data"
31 | "pg_read_all_settings"
32 | "pg_read_all_stats"
33 | "pg_signal_backend"
34 | "pg_stat_scan_tables"
35 | "pg_use_reserved_connections"
36 | "pg_write_all_data"
37 | "postgres"
38 | (string & {});
39
40type SDKInheritedRole =
41 | "pscale_managed"
42 | "pg_checkpoint"
43 | "pg_create_subscription"
44 | "pg_maintain"
45 | "pg_monitor"
46 | "pg_read_all_data"
47 | "pg_read_all_settings"
48 | "pg_read_all_stats"
49 | "pg_signal_backend"
50 | "pg_stat_scan_tables"
51 | "pg_use_reserved_connections"
52 | "pg_write_all_data"
53 | "postgres";
54
55/**
56 * Properties for creating or updating a PlanetScale PostgreSQL role.
57 *
58 * Roles are only meant for PostgreSQL databases. For MySQL,
59 * use {@link MySQLPassword} instead.
60 */
61export interface PostgresRoleProps {
62 /**
63 * Role name. If not provided, a physical name will be generated.
64 */
65 name?: string;
66 /**
67 * PostgreSQL database — string name or {@link PostgresDatabase} resource.
68 */
69 database: string | PostgresDatabase;
70
71 /**
72 * Branch — string name or {@link PostgresBranch} resource.
73 * @default "main"
74 */
75 branch?: string | PostgresBranch;
76
77 /**
78 * Time to live in seconds.
79 */
80 ttl?: number;
81
82 /**
83 * Roles to inherit from. Either a list of built-in role names, or
84 * another {@link PostgresRole} resource (whose `inheritedRoles` are reused).
85 */
86 inheritedRoles: InheritedRole[] | PostgresRole;
87
88 /**
89 * Give the role the REPLICATION attribute, for a logical-replication
90 * consumer (Electric, Debezium, a CDC pipeline). PlanetScale only grants
91 * it alongside `inheritedRoles: ["postgres"]`. Create-only: changing it
92 * replaces the role.
93 * @default false
94 */
95 withReplication?: boolean;
96
97 /**
98 * Successor role to reassign ownership to before dropping. Used during
99 * delete.
100 * @default "postgres"
101 */
102 successor?: string | PostgresRole;
103}
104
105/**
106 * Output attributes of a deployed PlanetScale role.
107 */
108export interface PostgresRoleAttributes {
109 /** Unique identifier for the role (stable). */
110 id: string;
111 /** The Postgres role name (assigned by PlanetScale). */
112 name: string;
113 /** ISO 8601 timestamp at which the role expires. */
114 expiresAt: string | null;
115 /** Hostname for the database connection. */
116 host: string;
117 /** Username for database authentication. */
118 username: string;
119 /** Password for database authentication (Redacted). */
120 password: Redacted.Redacted<string>;
121 /** The database name. */
122 database: string;
123 /** The Postgres database name inside the branch. */
124 databaseName: string;
125 /** Parsed direct (port 5432) connection components ready to feed into Cloudflare Hyperdrive. */
126 origin: PostgresOrigin;
127 /** Parsed pooled (PSBouncer, port 6432) connection components, e.g. for a Hyperdrive `dev` origin. */
128 pooledOrigin: PostgresOrigin;
129 /**
130 * Private connection host or DNS zone for this branch, supplied by
131 * PlanetScale. GCP Private Service Connect requires an endpoint name before
132 * this DNS zone.
133 */
134 privateHost: string;
135 /** Service name for this branch supplied by PlanetScale to establish a private connection. */
136 privateConnectionServiceName: string;
137 /** Direct connection URL for the database (Redacted). */
138 connectionUrl: Redacted.Redacted<string>;
139 /** Pooled connection URL via PSBouncer (port 6432, Redacted). */
140 connectionUrlPooled: Redacted.Redacted<string>;
141 /** Inherited roles. */
142 inheritedRoles: InheritedRole[];
143 /** Whether the role carries the REPLICATION attribute. */
144 withReplication: boolean;
145 /** The successor role used during delete. */
146 successor: string;
147 /** Resolved organization slug. */
148 organization: string;
149 /** Resolved branch name. */
150 branch: string;
151 /** Time-to-live for the role in seconds. */
152 ttl: number | null;
153}
154
155/**
156 * A PlanetScale role for accessing a PostgreSQL database branch.
157 *
158 * For MySQL databases, use {@link MySQLPassword} instead.
159 *
160 * ### Creating a Role
161 * **Example:** Postgres admin role
162 * ```typescript
163 * const admin = yield* Planetscale.PostgresRole("Admin", {
164 * database: "my-db",
165 * inheritedRoles: ["postgres"],
166 * });
167 * ```
168 *
169 * **Example:** Read-only role
170 * ```typescript
171 * const reader = yield* Planetscale.PostgresRole("Reader", {
172 * database: "my-db",
173 * inheritedRoles: ["pg_read_all_data", "pg_read_all_settings"],
174 * });
175 * ```
176 *
177 * **Example:** Role with TTL
178 * ```typescript
179 * const tempReader = yield* Planetscale.PostgresRole("TempReader", {
180 * database: "my-db",
181 * inheritedRoles: ["pg_read_all_data"],
182 * ttl: 3600,
183 * });
184 * ```
185 */
186export type PostgresRole = Resource<
187 "Planetscale.PostgresRole",
188 PostgresRoleProps,
189 PostgresRoleAttributes,
190 never,
191 Providers
192>;
193
194/** @resource */
195export const PostgresRole = Resource<PostgresRole>("Planetscale.PostgresRole");
196
197export const PostgresRoleProvider = () =>
198 Provider.succeed(PostgresRole, {
199 stables: ["id"],
200
201 diff: Effect.fn(function* ({ id, news, olds, output }) {
202 if (!isResolved(news)) return undefined;
203
204 // Successor is the only updatable property — everything else
205 // requires replacement.
206 if (news.ttl !== olds.ttl) {
207 return { action: "replace" } as const;
208 }
209 const newDb = resolveDatabaseName(news.database);
210 const oldDb = olds.database
211 ? resolveDatabaseName(olds.database)
212 : undefined;
213 if (oldDb && newDb !== oldDb) {
214 return { action: "replace" } as const;
215 }
216 const newBranch = resolveBranchName(news.branch);
217 const oldBranch = resolveBranchName(olds.branch);
218 if (newBranch !== oldBranch) {
219 return { action: "replace" } as const;
220 }
221 const newRoles = [...resolveInheritedRoles(news.inheritedRoles)].sort();
222 const oldRoles = [...(output?.inheritedRoles ?? [])].sort();
223 if (!deepEqual(newRoles, oldRoles)) {
224 return { action: "replace" } as const;
225 }
226 if (
227 (news.withReplication ?? false) !== (output?.withReplication ?? false)
228 ) {
229 return { action: "replace" } as const;
230 }
231 const oldName = output?.name ?? (yield* resolveName(id, olds?.name));
232 const newName = yield* resolveName(id, news.name);
233
234 if (newName !== oldName) {
235 return { action: "update" } as const;
236 }
237
238 return undefined;
239 }),
240
241 read: Effect.fn(function* ({ output }) {
242 if (!output?.id) return undefined;
243
244 return yield* planetscale
245 .getRole({
246 branch: output.branch,
247 database: output.database,
248 organization: output.organization,
249 id: output.id,
250 })
251 .pipe(
252 Effect.map((token) =>
253 buildAttributes(token, output.password, {
254 inheritedRoles: token.inherited_roles as InheritedRole[],
255 successor: output.successor,
256 organization: output.organization,
257 database: output.database,
258 branch: token.branch.name,
259 }),
260 ),
261 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
262 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
263 );
264 }),
265
266 reconcile: Effect.fn(function* ({ id, news, output }) {
267 const { organization: envOrg } = yield* yield* Credentials;
268 const organization = resolveDatabaseOrg(news.database) ?? envOrg;
269 const databaseName = resolveDatabaseName(news.database);
270 const branchName = resolveBranchName(news.branch);
271 const inheritedRoles = resolveInheritedRoles(news.inheritedRoles);
272 const successor = resolveSuccessorName(news.successor);
273 const desiredName = yield* resolveName(id, news.name);
274
275 // 1. Observe — fetch live state if we know the role id. Roles
276 // can only be looked up by id; without a cached id there is
277 // no way to find the live role, so we fall through to ensure.
278 const observed = output?.id
279 ? yield* planetscale
280 .getRole({
281 id: output.id,
282 branch: branchName,
283 organization,
284 database: databaseName,
285 })
286 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)))
287 : undefined;
288
289 // 2. Ensure — create if missing. Single branch covers both
290 // greenfield (no output) and out-of-band-deletion (output
291 // cached but cloud lost it). The latter is structurally a
292 // rotation: the new role has fresh id/host/username/plaintext
293 // that propagate downstream through the dependency graph.
294 // There is no path to recover the original plaintext, so
295 // recreating is the only way to converge.
296 //
297 // Planetscale returns the plaintext password value exactly
298 // once on create; on an observe-hit we re-use the cached
299 // plaintext from output (which is why adoption requires
300 // pre-existing state — see `read`).
301 let live: planetscale.GetRoleOutput | planetscale.CreateRoleOutput;
302 let plaintext: Redacted.Redacted<string>;
303 if (observed) {
304 live = observed;
305 plaintext = output!.password;
306 } else {
307 const branchInfo = yield* planetscale.getBranch({
308 organization,
309 database: databaseName,
310 branch: branchName,
311 });
312 if (branchInfo.kind !== "postgresql") {
313 return yield* Effect.fail(
314 new PlanetscaleConflict({
315 message: `Cannot create a Role on MySQL database "${databaseName}". Roles are only supported on PostgreSQL. Use Password for MySQL.`,
316 }),
317 );
318 }
319 if (!branchInfo.ready) {
320 yield* waitForBranchReady(organization, databaseName, branchName);
321 }
322 const created = yield* planetscale.createRole({
323 name: desiredName,
324 branch: branchName,
325 organization,
326 database: databaseName,
327 ttl: news.ttl,
328 inherited_roles: inheritedRoles as SDKInheritedRole[],
329 with_replication: news.withReplication,
330 });
331 if (!created.password) {
332 return yield* Effect.die(
333 `Planetscale did not return a password for Role "${desiredName}".`,
334 );
335 }
336 plaintext = created.password;
337 live = created;
338 }
339
340 // 3. Sync — only `name` is mutable in place; diff observed
341 // against desired and skip the API call when nothing
342 // changed. Everything else triggers a replace via diff().
343 if (live.name !== desiredName) {
344 live = yield* planetscale.updateRole({
345 id: live.id,
346 branch: branchName,
347 organization,
348 database: databaseName,
349 name: desiredName,
350 });
351 }
352
353 // 4. Return — fresh attrs sourced from observed cloud state,
354 // with cached or freshly-issued plaintext.
355 return buildAttributes(live, plaintext, {
356 inheritedRoles: live.inherited_roles as InheritedRole[],
357 successor,
358 organization,
359 database: databaseName,
360 branch: branchName,
361 });
362 }),
363
364 delete: Effect.fn(function* ({ output }) {
365 yield* planetscale
366 .deleteRole({
367 organization: output.organization,
368 database: output.database,
369 branch: output.branch,
370 id: output.id,
371 successor: output.successor,
372 })
373 .pipe(
374 Effect.catchTag("NotFound", () => Effect.void),
375 // 422 (UnprocessableEntity): role is still referenced. Log a
376 // warning rather than failing — the role will be cleaned up
377 // when the database/branch is deleted.
378 Effect.catchIf(
379 isKnownError(
380 "UnprocessableEntity",
381 "Role is still referenced and cannot be dropped.",
382 ),
383 () => Effect.void,
384 ),
385 );
386 }),
387
388 list: Effect.fn(function* () {
389 const { organization } = yield* yield* Credentials;
390
391 const databases = yield* planetscale.listDatabases
392 .pages({ organization })
393 .pipe(
394 Stream.runCollect,
395 Effect.map((chunk) =>
396 Array.from(chunk).flatMap((page) =>
397 page.data.filter((db) => db.kind === "postgresql"),
398 ),
399 ),
400 );
401
402 const rows = yield* Effect.forEach(
403 databases,
404 (db) =>
405 planetscale.listBranches
406 .pages({ organization, database: db.name })
407 .pipe(
408 Stream.runCollect,
409 Effect.map((branchPages) =>
410 Array.from(branchPages).flatMap((page) =>
411 page.data.filter((branch) => branch.kind === "postgresql"),
412 ),
413 ),
414 Effect.catchTag("NotFound", () =>
415 Effect.succeed([{ name: db.default_branch ?? "main" }]),
416 ),
417 Effect.flatMap((branches) =>
418 Effect.forEach(
419 branches,
420 (branch) =>
421 planetscale.listRoles
422 .pages({
423 organization,
424 database: db.name,
425 branch: branch.name,
426 })
427 .pipe(
428 Stream.runCollect,
429 Effect.map((rolePages): PostgresRoleAttributes[] =>
430 Array.from(rolePages).flatMap((page) =>
431 page.data
432 .filter((role) => !role.default)
433 .map((role) =>
434 buildAttributes(role, Redacted.make(""), {
435 inheritedRoles:
436 role.inherited_roles as InheritedRole[],
437 successor: "postgres",
438 organization,
439 database: db.name,
440 branch: branch.name,
441 }),
442 ),
443 ),
444 ),
445 Effect.catchTag("NotFound", () =>
446 Effect.succeed([] as PostgresRoleAttributes[]),
447 ),
448 Effect.catchTag("Forbidden", () =>
449 Effect.succeed([] as PostgresRoleAttributes[]),
450 ),
451 ),
452 { concurrency: 10 },
453 ).pipe(Effect.map((perBranch) => perBranch.flat())),
454 ),
455 ),
456 { concurrency: 10 },
457 );
458
459 return rows.flat();
460 }),
461 });
462
463// Structural shapes for runtime-resolved Resource references — see notes
464// in Password.ts.
465type DatabaseRef = string | { name: string; organization?: string };
466type BranchRef = string | { name: string };
467type RoleRef = string | { name: string; inheritedRoles: InheritedRole[] };
468
469const resolveDatabaseName = (database: string | PostgresDatabase): string => {
470 const ref = database as unknown as DatabaseRef;
471 return typeof ref === "string" ? ref : ref.name;
472};
473
474const resolveDatabaseOrg = (
475 database: string | PostgresDatabase,
476): string | undefined => {
477 const ref = database as unknown as DatabaseRef;
478 return typeof ref === "string" ? undefined : ref.organization;
479};
480
481const resolveBranchName = (
482 branch: string | PostgresBranch | undefined,
483): string => {
484 const ref = branch as unknown as BranchRef | undefined;
485 return !ref ? "main" : typeof ref === "string" ? ref : ref.name;
486};
487
488const resolveSuccessorName = (
489 successor: string | PostgresRole | undefined,
490): string => {
491 const ref = successor as unknown as RoleRef | undefined;
492 return !ref ? "postgres" : typeof ref === "string" ? ref : ref.name;
493};
494
495const resolveInheritedRoles = (
496 inheritedRoles: InheritedRole[] | PostgresRole,
497): InheritedRole[] => {
498 if (Array.isArray(inheritedRoles)) {
499 return inheritedRoles;
500 }
501 // At runtime, a Role passed in is its resolved attributes (with
502 // `inheritedRoles` as a plain array); statically `Role.inheritedRoles`
503 // is `Output<InheritedRole[]>`.
504 return (
505 inheritedRoles as unknown as RoleRef as { inheritedRoles: InheritedRole[] }
506 ).inheritedRoles;
507};
508
509const resolveName = (id: string, name: string | undefined) =>
510 Effect.gen(function* () {
511 return (
512 name ??
513 (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }))
514 );
515 });
516
517const buildAttributes = (
518 role: {
519 id: string;
520 name: string;
521 expires_at: string | null;
522 with_replication: boolean;
523 access_host_url: string;
524 private_access_host_url: string;
525 private_connection_service_name: string;
526 username: string;
527 database_name: string;
528 ttl: number | null;
529 },
530 password: Redacted.Redacted<string>,
531 context: {
532 inheritedRoles: InheritedRole[];
533 successor: string;
534 organization: string;
535 database: string;
536 branch: string;
537 },
538): PostgresRoleAttributes => {
539 const passwordValue = Redacted.value(password);
540 const connectionUrl = `postgresql://${role.username}:${passwordValue}@${role.access_host_url}:5432/${role.database_name}?sslmode=verify-full`;
541 const connectionUrlPooled = `postgresql://${role.username}:${passwordValue}@${role.access_host_url}:6432/${role.database_name}?sslmode=verify-full`;
542
543 return {
544 id: role.id,
545 name: role.name,
546 expiresAt: role.expires_at,
547 host: role.access_host_url,
548 username: role.username,
549 password,
550 connectionUrl: Redacted.make(connectionUrl),
551 connectionUrlPooled: Redacted.make(connectionUrlPooled),
552 privateHost: role.private_access_host_url,
553 privateConnectionServiceName: role.private_connection_service_name,
554 inheritedRoles: context.inheritedRoles,
555 withReplication: role.with_replication,
556 successor: context.successor,
557 organization: context.organization,
558 database: context.database,
559 databaseName: role.database_name,
560 origin: {
561 scheme: "postgres",
562 host: role.access_host_url,
563 port: 5432,
564 database: role.database_name,
565 user: role.username,
566 password,
567 },
568 pooledOrigin: {
569 scheme: "postgres",
570 host: role.access_host_url,
571 port: 6432,
572 database: role.database_name,
573 user: role.username,
574 password,
575 },
576 branch: context.branch,
577 ttl: role.ttl,
578 };
579};