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 type { MySQLBranch } from "./MySQLBranch.ts";
12import type { MySQLDatabase } from "./MySQLDatabase.ts";
13import type { MySQLOrigin } from "./MySQLOrigin.ts";
14
15/**
16 * Properties for creating or updating a PlanetScale MySQL password.
17 *
18 * Passwords are only meant for MySQL databases. For PostgreSQL,
19 * use {@link PostgresRole} instead.
20 */
21export interface MySQLPasswordProps {
22 /**
23 * Password name. If omitted, a unique name is generated from
24 * `${app}-${stage}-${id}`.
25 */
26 name?: string;
27
28 /**
29 * MySQL database — string name or {@link MySQLDatabase} resource.
30 */
31 database: string | MySQLDatabase;
32
33 /**
34 * Branch — string name or {@link MySQLBranch} resource.
35 * @default "main"
36 */
37 branch?: string | MySQLBranch;
38
39 /**
40 * The MySQL role granted to the password.
41 */
42 role: "reader" | "writer" | "admin" | "readwriter";
43
44 /**
45 * Whether the password is for a read replica.
46 */
47 replica?: boolean;
48
49 /**
50 * Time to live (seconds). The password is invalidated after this period.
51 */
52 ttl?: number;
53
54 /**
55 * IP CIDR ranges allowed to use this password.
56 */
57 cidrs?: string[];
58}
59
60/**
61 * Output attributes of a deployed PlanetScale password.
62 */
63export interface MySQLPasswordAttributes {
64 /** Unique identifier for the password (stable). */
65 id: string;
66 /** Password name. If omitted, a unique name is generated from `${app}-${stage}-${id}`. */
67 name: string;
68 /** ISO 8601 timestamp at which the password expires. `null` if no TTL. */
69 expiresAt: string | null;
70 /** Hostname for the database connection. */
71 host: string;
72 /** Username for database authentication. */
73 username: string;
74 /** Password for database authentication (Redacted). */
75 password: Redacted.Redacted<string>;
76 /** Parsed connection components ready to feed into Cloudflare Hyperdrive. */
77 origin: MySQLOrigin;
78 /** Resolved organization slug. */
79 organization: string;
80 /** Resolved database name. */
81 database: string;
82 /** Resolved branch name. */
83 branch: string;
84 /** The role granted. */
85 role: "reader" | "writer" | "admin" | "readwriter" | (string & {});
86 /** Whether this password is for a read replica. */
87 replica: boolean | undefined;
88 /** TTL in seconds (if set). */
89 ttl: number | undefined;
90 /** IP CIDR ranges allowed to use this password. */
91 cidrs: readonly string[] | undefined;
92}
93
94/**
95 * A PlanetScale password for accessing a MySQL database branch.
96 *
97 * For PostgreSQL databases, use {@link PostgresRole} instead.
98 *
99 * ### Creating a Password
100 * **Example:** Reader password
101 * ```typescript
102 * const reader = yield* Planetscale.MySQLPassword("AppReader", {
103 * database: "my-db",
104 * role: "reader",
105 * });
106 * ```
107 *
108 * **Example:** Writer password with TTL
109 * ```typescript
110 * const writer = yield* Planetscale.MySQLPassword("AppWriter", {
111 * database: "my-db",
112 * role: "writer",
113 * ttl: 86400,
114 * });
115 * ```
116 *
117 * **Example:** Admin password with IP allowlist
118 * ```typescript
119 * const admin = yield* Planetscale.MySQLPassword("Admin", {
120 * database: "my-db",
121 * role: "admin",
122 * cidrs: ["203.0.113.0/24", "198.51.100.0/24"],
123 * });
124 * ```
125 */
126export type MySQLPassword = Resource<
127 "Planetscale.MySQLPassword",
128 MySQLPasswordProps,
129 MySQLPasswordAttributes,
130 never,
131 Providers
132>;
133
134/** @resource */
135export const MySQLPassword = Resource<MySQLPassword>(
136 "Planetscale.MySQLPassword",
137);
138
139export const MySQLPasswordProvider = () =>
140 Provider.succeed(MySQLPassword, {
141 stables: ["id"],
142 diff: Effect.fn(function* ({ olds = {}, news }) {
143 if (!isResolved(news)) return undefined;
144
145 // Replace on any immutable-in-place change; engine falls through
146 // to reconcile for everything else (which authoritatively diffs
147 // against observed cloud state).
148 if (news.role !== olds.role) return { action: "replace" } as const;
149 if (news.replica !== olds.replica) {
150 return { action: "replace" } as const;
151 }
152 if (news.ttl !== olds.ttl) return { action: "replace" } as const;
153
154 const newDb = resolveDatabaseName(news.database);
155 const oldDb = olds.database
156 ? resolveDatabaseName(olds.database)
157 : undefined;
158 if (oldDb && newDb !== oldDb) {
159 return { action: "replace" } as const;
160 }
161 const newBranch = resolveBranchName(news.branch);
162 const oldBranch = resolveBranchName(olds.branch);
163 if (newBranch !== oldBranch) {
164 return { action: "replace" } as const;
165 }
166 return undefined;
167 }),
168
169 read: Effect.fn(function* ({ output }) {
170 // Adoption (no cached output) is impossible: the PlanetScale API
171 // never re-issues plaintext, so even if we found a matching live
172 // password by listing+name we couldn't deliver the `password`
173 // attribute. Fall through to greenfield create in `reconcile`.
174 if (!output) return undefined;
175
176 // Refresh — verify the cached password still exists and
177 // re-emit the persisted output (with plaintext intact).
178 // If it's gone, return undefined so the engine routes to
179 // reconcile, which will surface the unrecoverable-rotation
180 // condition explicitly.
181 return yield* planetscale
182 .getPassword({
183 organization: output.organization,
184 database: output.database,
185 branch: output.branch,
186 id: output.id,
187 })
188 .pipe(
189 Effect.map((password) =>
190 buildAttributes(password, output.password, {
191 organization: output.organization,
192 database: output.database,
193 branch: output.branch,
194 }),
195 ),
196 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
197 );
198 }),
199
200 reconcile: Effect.fn(function* ({ id, news, output }) {
201 const newName = yield* createPasswordName(id, news.name);
202
203 // Identifiers — stable across update (diff replaces on
204 // database/branch changes), so we can pull from `output` first
205 // and fall back to `news` for greenfield.
206 const { organization: envOrg } = yield* yield* Credentials;
207 const organization =
208 output?.organization ?? resolveDatabaseOrg(news.database) ?? envOrg;
209 const databaseName =
210 output?.database ?? resolveDatabaseName(news.database);
211 const branchName = output?.branch ?? resolveBranchName(news.branch);
212
213 // 1. Observe — read live state. The PlanetScale API has no
214 // "lookup by name" path (names aren't unique within a branch)
215 // so the only way to find the live password is by id. Without
216 // a cached output we cannot observe and fall straight through
217 // to ensure (greenfield create).
218 const observed = output
219 ? yield* planetscale
220 .getPassword({
221 organization,
222 database: databaseName,
223 branch: branchName,
224 id: output.id,
225 })
226 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)))
227 : undefined;
228
229 // 2. Ensure — create the password if it doesn't exist live.
230 // This single branch covers greenfield (no output) AND the
231 // out-of-band-deletion case (output cached but cloud lost it).
232 // The latter is structurally a rotation: the new password
233 // has a fresh id/host/username/plaintext that propagate
234 // downstream through the dependency graph. There is no path
235 // to recover the original plaintext, so recreating is the
236 // only way to converge.
237 let live: planetscale.GetPasswordOutput;
238 let plaintext: Redacted.Redacted<string>;
239 if (observed) {
240 live = observed;
241 // We re-use the cached plaintext: the API never re-issues it.
242 plaintext = output!.password;
243 } else {
244 const created = yield* planetscale.createPassword({
245 organization,
246 database: databaseName,
247 branch: branchName,
248 name: newName,
249 role: news.role,
250 replica: news.replica,
251 ttl: news.ttl,
252 cidrs: news.cidrs,
253 });
254 plaintext = created.plain_text;
255 live = created;
256 }
257
258 // 3. Sync — diff observed cloud state against desired and patch
259 // only what changed. Only `name` and `cidrs` are mutable in
260 // place; everything else triggers a replace via diff().
261 // Skip the API entirely when there's no delta.
262 const wantsRename = newName !== live.name;
263 const observedCidrs = normalizeCidrs(live.cidrs);
264 const desiredCidrs = normalizeCidrs(news.cidrs);
265 const wantsCidrs = !deepEqual(desiredCidrs, observedCidrs);
266
267 if (wantsRename || wantsCidrs) {
268 live = yield* planetscale.updatePassword({
269 organization,
270 database: databaseName,
271 branch: branchName,
272 id: live.id,
273 name: wantsRename ? newName : undefined,
274 cidrs: wantsCidrs ? news.cidrs : undefined,
275 });
276 }
277
278 // 4. Return — fresh attrs sourced from observed cloud state,
279 // re-using cached plaintext when we didn't just create.
280 return buildAttributes(live, plaintext, {
281 organization,
282 database: databaseName,
283 branch: branchName,
284 });
285 }),
286
287 delete: Effect.fn(function* ({ output }) {
288 yield* planetscale
289 .deletePassword({
290 organization: output.organization,
291 database: output.database,
292 branch: output.branch,
293 id: output.id,
294 })
295 .pipe(Effect.catchTag("NotFound", () => Effect.void));
296 }),
297
298 list: Effect.fn(function* () {
299 const { organization } = yield* yield* Credentials;
300
301 const databases = yield* planetscale.listDatabases
302 .pages({ organization })
303 .pipe(
304 Stream.runCollect,
305 Effect.map((chunk) =>
306 Array.from(chunk).flatMap((page) =>
307 page.data.filter((db) => db.kind === "mysql"),
308 ),
309 ),
310 );
311
312 const rows = yield* Effect.forEach(
313 databases,
314 (db) =>
315 planetscale.listBranches
316 .pages({ organization, database: db.name })
317 .pipe(
318 Stream.runCollect,
319 Effect.map((branchPages) =>
320 Array.from(branchPages).flatMap((page) =>
321 page.data.filter((branch) => branch.kind === "mysql"),
322 ),
323 ),
324 Effect.catchTag("NotFound", () =>
325 Effect.succeed([{ name: db.default_branch ?? "main" }]),
326 ),
327 Effect.flatMap((branches) =>
328 Effect.forEach(
329 branches,
330 (branch) =>
331 planetscale.listPasswords
332 .pages({
333 organization,
334 database: db.name,
335 branch: branch.name,
336 })
337 .pipe(
338 Stream.runCollect,
339 Effect.map((passwordPages): MySQLPasswordAttributes[] =>
340 Array.from(passwordPages).flatMap((page) =>
341 page.data.map((password) =>
342 buildAttributes(password, Redacted.make(""), {
343 organization,
344 database: db.name,
345 branch: branch.name,
346 }),
347 ),
348 ),
349 ),
350 Effect.catchTag("NotFound", () =>
351 Effect.succeed([] as MySQLPasswordAttributes[]),
352 ),
353 ),
354 { concurrency: 10 },
355 ).pipe(Effect.map((perBranch) => perBranch.flat())),
356 ),
357 ),
358 { concurrency: 10 },
359 );
360
361 return rows.flat();
362 }),
363 });
364
365// At runtime, Resource references in props are resolved to their full
366// attributes — so `news.database` (when a Database resource was passed)
367// is `{ name: string; organization: string; ... }` rather than the
368// statically-typed Resource (whose attributes are `Output<...>`). We
369// cast through structural shapes here.
370type DatabaseRef = string | { name: string; organization?: string };
371type BranchRef = string | { name: string };
372
373const resolveDatabaseName = (database: string | MySQLDatabase): string => {
374 const ref = database as unknown as DatabaseRef;
375 return typeof ref === "string" ? ref : ref.name;
376};
377
378const resolveDatabaseOrg = (
379 database: string | MySQLDatabase,
380): string | undefined => {
381 const ref = database as unknown as DatabaseRef;
382 return typeof ref === "string" ? undefined : ref.organization;
383};
384
385const resolveBranchName = (
386 branch: string | MySQLBranch | undefined,
387): string => {
388 const ref = branch as unknown as BranchRef | undefined;
389 return !ref ? "main" : typeof ref === "string" ? ref : ref.name;
390};
391
392const createPasswordName = (id: string, name: string | undefined) =>
393 Effect.gen(function* () {
394 return (
395 name ??
396 (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }))
397 );
398 });
399
400// Normalize the API's nullable `cidrs` field into the `string[] | undefined`
401// shape we persist so downstream comparisons line up regardless of the
402// API returning `null` vs `[]` vs an actual list.
403const normalizeCidrs = (
404 cidrs: readonly string[] | null | undefined,
405): readonly string[] | undefined =>
406 cidrs == null || cidrs.length === 0 ? undefined : cidrs;
407
408const buildAttributes = (
409 password: {
410 id: string;
411 name: string;
412 expires_at: string | null;
413 access_host_url: string;
414 username: string;
415 role: "reader" | "writer" | "admin" | "readwriter" | (string & {});
416 replica: boolean;
417 ttl_seconds: number | null;
418 cidrs: readonly string[] | null;
419 },
420 plaintext: Redacted.Redacted<string>,
421 context: {
422 organization: string;
423 database: string;
424 branch: string;
425 },
426): MySQLPasswordAttributes => ({
427 id: password.id,
428 name: password.name,
429 expiresAt: password.expires_at,
430 host: password.access_host_url,
431 username: password.username,
432 password: plaintext,
433 origin: {
434 scheme: "mysql",
435 host: password.access_host_url,
436 port: 3306,
437 database: context.database,
438 user: password.username,
439 password: plaintext,
440 },
441 organization: context.organization,
442 database: context.database,
443 branch: context.branch,
444 role: password.role,
445 replica: password.replica,
446 ttl: password.ttl_seconds ?? undefined,
447 cidrs: normalizeCidrs(password.cidrs),
448});