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 { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { PlanetscaleConflict, waitForBranchReady } from "../Util.ts";
11import type { PostgresBranch } from "./PostgresBranch.ts";
12import type { PostgresDatabase } from "./PostgresDatabase.ts";
13import type { InheritedRole } from "./PostgresRole.ts";
14
15/**
16 * Properties for creating or updating the default PlanetScale PostgreSQL
17 * role for a branch.
18 *
19 * Default roles are only meant for PostgreSQL databases. For MySQL,
20 * use {@link MySQLPassword} instead.
21 */
22export interface PostgresDefaultRoleProps {
23 /**
24 * PostgreSQL database — string name or {@link PostgresDatabase} resource.
25 */
26 database: string | PostgresDatabase;
27
28 /**
29 * Branch — string name or {@link PostgresBranch} resource.
30 * @default "main"
31 */
32 branch?: string | PostgresBranch;
33
34 /**
35 * Whether to force-reset the default role if it already exists.
36 * Adopting an existing default role is not supported because the
37 * password is only returned at create time.
38 * @default false
39 */
40 forceReset?: boolean;
41}
42
43/**
44 * Output attributes of a deployed PlanetScale default role.
45 */
46export interface PostgresDefaultRoleAttributes {
47 /** Unique identifier for the role (stable). */
48 id: string;
49 /** The Postgres role name. */
50 name: string;
51 /** ISO 8601 timestamp at which the role expires. */
52 expiresAt: string | null;
53 /** Hostname for the database connection. */
54 host: string;
55 /** Username for database authentication. */
56 username: string;
57 /** Password for database authentication (Redacted). */
58 password: Redacted.Redacted<string>;
59 /** TTL in seconds (if set). */
60 ttl: number | null;
61 /** The database name. */
62 databaseName: string;
63 /** Direct connection URL for the database (Redacted). */
64 connectionUrl: Redacted.Redacted<string>;
65 /** Pooled connection URL via PSBouncer (Redacted). */
66 connectionUrlPooled: Redacted.Redacted<string>;
67 /**
68 * Private connection host or DNS zone for this branch, supplied by
69 * PlanetScale. GCP Private Service Connect requires an endpoint name before
70 * this DNS zone.
71 */
72 privateHost: string;
73 /** Service name for this branch supplied by PlanetScale to establish a private connection. */
74 privateConnectionServiceName: string;
75 /** Inherited roles. */
76 inheritedRoles: InheritedRole[];
77 /** Resolved organization slug. */
78 organization: string;
79 /** Resolved database name (for delete). */
80 database: string;
81 /** Resolved branch name. */
82 branch: string;
83}
84
85/**
86 * The default PlanetScale PostgreSQL role for a database branch.
87 *
88 * ### Creating a Default Role
89 * **Example:** Default role on the main branch
90 * ```typescript
91 * const db = yield* Planetscale.PostgresDatabase("MyDb", {
92 * clusterSize: "PS_10",
93 * });
94 * const defaultRole = yield* Planetscale.PostgresDefaultRole("MainRole", {
95 * database: db,
96 * forceReset: true,
97 * });
98 * ```
99 */
100export type PostgresDefaultRole = Resource<
101 "Planetscale.PostgresDefaultRole",
102 PostgresDefaultRoleProps,
103 PostgresDefaultRoleAttributes,
104 never,
105 Providers
106>;
107
108/** @resource */
109export const PostgresDefaultRole = Resource<PostgresDefaultRole>(
110 "Planetscale.PostgresDefaultRole",
111);
112
113export const PostgresDefaultRoleProvider = () =>
114 Provider.succeed(PostgresDefaultRole, {
115 stables: ["id"],
116 diff: Effect.fn(function* ({ news, olds = {} }) {
117 if (!isResolved(news)) return undefined;
118
119 const newDb = resolveDatabaseName(news.database);
120 const oldDb = olds.database
121 ? resolveDatabaseName(olds.database)
122 : undefined;
123 if (oldDb && newDb !== oldDb) {
124 return { action: "replace" } as const;
125 }
126 const newBranch = resolveBranchName(news.branch);
127 const oldBranch = resolveBranchName(olds.branch);
128 if (newBranch !== oldBranch) {
129 return { action: "replace" } as const;
130 }
131 return undefined;
132 }),
133
134 read: Effect.fn(function* ({ output }) {
135 // Adoption (no cached output) is impossible without destroying
136 // existing credentials. The engine routes adoption through
137 // `read`, but here we always defer to reconcile so the
138 // `forceReset` props flag remains the user-facing switch for
139 // takeover. Returning undefined makes the engine treat the
140 // resource as greenfield; reconcile then observes live state
141 // and either fails (no forceReset) or resets (forceReset).
142 if (!output) return undefined;
143
144 // Refresh — verify the default role still exists and re-emit
145 // the persisted output (with plaintext intact). If gone,
146 // return undefined so the engine routes through reconcile,
147 // which will recreate via reset.
148 return yield* planetscale
149 .getDefaultRole({
150 organization: output.organization,
151 database: output.database,
152 branch: output.branch,
153 })
154 .pipe(
155 Effect.map((role) => ({
156 ...output,
157 privateHost: role.private_access_host_url,
158 privateConnectionServiceName: role.private_connection_service_name,
159 })),
160 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
161 );
162 }),
163
164 reconcile: Effect.fn(function* ({ news, output }) {
165 const { organization: envOrg } = yield* yield* Credentials;
166 const organization = resolveDatabaseOrg(news.database) ?? envOrg;
167 const databaseName = resolveDatabaseName(news.database);
168 const branchName = resolveBranchName(news.branch);
169
170 // 1. Observe — the default role is the singleton for this
171 // (database, branch); we key on those rather than an id.
172 // Observation runs unconditionally so the adoption guard
173 // below fires on greenfield deploys against branches that
174 // already have a default role (otherwise we would silently
175 // `resetDefault` and destroy their credentials).
176 const observed = yield* planetscale
177 .getDefaultRole({
178 organization,
179 database: databaseName,
180 branch: branchName,
181 })
182 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
183
184 // 2. Sync (no-op) — we own the role and it still exists in the
185 // cloud. Default roles have nothing mutable in place: their
186 // plaintext password isn't retrievable, so we retain the cached
187 // credentials while refreshing the private connection details.
188 // diff() forces a replace when database/branch change.
189 if (output && observed) {
190 return {
191 ...output,
192 privateHost: observed.private_access_host_url,
193 privateConnectionServiceName:
194 observed.private_connection_service_name,
195 };
196 }
197
198 // 3. Adoption guard — observed exists but we have no prior
199 // output (greenfield against a pre-existing default role).
200 // Taking over requires a reset (which destroys the existing
201 // password), so gate on an explicit forceReset opt-in.
202 if (observed && !news.forceReset) {
203 return yield* Effect.fail(
204 new PlanetscaleConflict({
205 message: `Default role already exists for database "${databaseName}" branch "${branchName}". Use forceReset: true to reset.`,
206 }),
207 );
208 }
209
210 // 4. Ensure — (re)create the default role. This branch runs
211 // for greenfield (no output, no observed), drift recovery
212 // (output cached but cloud state gone), and authorised
213 // adoption (observed present + forceReset).
214 yield* waitForBranchReady(organization, databaseName, branchName);
215 const data = yield* planetscale.resetDefaultRole({
216 organization,
217 database: databaseName,
218 branch: branchName,
219 });
220
221 if (!data.password) {
222 return yield* Effect.die(
223 `Planetscale did not return a password for Default Role.`,
224 );
225 }
226
227 const password = Redacted.isRedacted(data.password)
228 ? Redacted.value(data.password)
229 : data.password;
230
231 const connectionUrl = `postgresql://${data.username}:${password}@${data.access_host_url}:5432/${data.database_name}?sslmode=verify-full`;
232 const connectionUrlPooled = `postgresql://${data.username}:${password}@${data.access_host_url}:6432/${data.database_name}?sslmode=verify-full`;
233
234 return {
235 id: data.id,
236 name: data.name,
237 expiresAt: data.expires_at,
238 host: data.access_host_url,
239 username: data.username,
240 password: Redacted.make(password),
241 ttl: data.ttl,
242 databaseName: data.database_name,
243 connectionUrl: Redacted.make(connectionUrl),
244 connectionUrlPooled: Redacted.make(connectionUrlPooled),
245 privateHost: data.private_access_host_url,
246 privateConnectionServiceName: data.private_connection_service_name,
247 inheritedRoles: data.inherited_roles as InheritedRole[],
248 organization,
249 database: databaseName,
250 branch: branchName,
251 } satisfies PostgresDefaultRoleAttributes;
252 }),
253
254 delete: Effect.fn(function* ({ output }) {
255 // No delete endpoint — reset to invalidate existing credentials.
256 yield* planetscale
257 .resetDefaultRole({
258 organization: output.organization,
259 database: output.database,
260 branch: output.branch,
261 })
262 .pipe(Effect.catchTag("NotFound", () => Effect.void));
263 }),
264
265 list: Effect.fn(function* () {
266 const { organization } = yield* yield* Credentials;
267
268 const roles = yield* planetscale.listDatabases
269 .pages({ organization })
270 .pipe(
271 Stream.map((page) =>
272 page.data.filter((db) => db.kind === "postgresql"),
273 ),
274 Stream.flattenIterable,
275 Stream.flatMap(
276 (db) =>
277 planetscale.listBranches
278 .pages({ organization, database: db.name })
279 .pipe(
280 Stream.map((page) =>
281 page.data.filter((branch) => branch.kind === "postgresql"),
282 ),
283 Stream.flattenIterable,
284 Stream.catchTag("NotFound", () =>
285 Stream.succeed({ name: db.default_branch ?? "main" }),
286 ),
287 Stream.flatMap(
288 (branch) =>
289 planetscale.listRoles
290 .pages({
291 organization,
292 database: db.name,
293 branch: branch.name,
294 })
295 .pipe(
296 Stream.map((page) =>
297 page.data
298 .filter((role) => role.default)
299 .map(
300 (role) =>
301 ({
302 id: role.id,
303 name: role.name,
304 expiresAt: role.expires_at,
305 host: role.access_host_url,
306 username: role.username,
307 password: Redacted.make(""),
308 ttl: role.ttl,
309 databaseName: role.database_name,
310 connectionUrl: Redacted.make(""),
311 connectionUrlPooled: Redacted.make(""),
312 privateHost: role.private_access_host_url,
313 privateConnectionServiceName:
314 role.private_connection_service_name,
315 inheritedRoles:
316 role.inherited_roles as InheritedRole[],
317 organization,
318 database: db.name,
319 branch: branch.name,
320 }) satisfies PostgresDefaultRoleAttributes as PostgresDefaultRoleAttributes,
321 ),
322 ),
323 Stream.flattenIterable,
324 Stream.catchTags({
325 NotFound: () => Stream.empty,
326 Forbidden: () => Stream.empty,
327 }),
328 ),
329 { concurrency: 10 },
330 ),
331 ),
332 { concurrency: 10 },
333 ),
334 Stream.runCollect,
335 );
336
337 return Array.from(roles);
338 }),
339 });
340
341// Structural shapes for runtime-resolved Resource references — see
342// notes in Password.ts.
343type DatabaseRef = string | { name: string; organization?: string };
344type BranchRef = string | { name: string };
345
346const resolveDatabaseName = (database: string | PostgresDatabase): string => {
347 const ref = database as unknown as DatabaseRef;
348 return typeof ref === "string" ? ref : ref.name;
349};
350
351const resolveDatabaseOrg = (
352 database: string | PostgresDatabase,
353): string | undefined => {
354 const ref = database as unknown as DatabaseRef;
355 return typeof ref === "string" ? undefined : ref.organization;
356};
357
358const resolveBranchName = (
359 branch: string | PostgresBranch | undefined,
360): string => {
361 const ref = branch as unknown as BranchRef | undefined;
362 return !ref ? "main" : typeof ref === "string" ? ref : ref.name;
363};