@alchemy.run/planetscale 0.0.0 → 2.0.0-beta.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +83 -4
  3. package/lib/AuthProvider.d.ts +64 -0
  4. package/lib/AuthProvider.d.ts.map +1 -0
  5. package/lib/AuthProvider.js +395 -0
  6. package/lib/AuthProvider.js.map +1 -0
  7. package/lib/Branch.d.ts +128 -0
  8. package/lib/Branch.d.ts.map +1 -0
  9. package/lib/Branch.js +420 -0
  10. package/lib/Branch.js.map +1 -0
  11. package/lib/Contributions.d.ts +7 -0
  12. package/lib/Contributions.d.ts.map +1 -0
  13. package/lib/Contributions.js +6 -0
  14. package/lib/Contributions.js.map +1 -0
  15. package/lib/Credentials.d.ts +33 -0
  16. package/lib/Credentials.d.ts.map +1 -0
  17. package/lib/Credentials.js +59 -0
  18. package/lib/Credentials.js.map +1 -0
  19. package/lib/Database.d.ts +138 -0
  20. package/lib/Database.d.ts.map +1 -0
  21. package/lib/Database.js +1 -0
  22. package/lib/Database.js.map +1 -0
  23. package/lib/MySQL/MySQLBranch.d.ts +87 -0
  24. package/lib/MySQL/MySQLBranch.d.ts.map +1 -0
  25. package/lib/MySQL/MySQLBranch.js +15 -0
  26. package/lib/MySQL/MySQLBranch.js.map +1 -0
  27. package/lib/MySQL/MySQLClusterSize.d.ts +32 -0
  28. package/lib/MySQL/MySQLClusterSize.d.ts.map +1 -0
  29. package/lib/MySQL/MySQLClusterSize.js +111 -0
  30. package/lib/MySQL/MySQLClusterSize.js.map +1 -0
  31. package/lib/MySQL/MySQLDatabase.d.ts +141 -0
  32. package/lib/MySQL/MySQLDatabase.d.ts.map +1 -0
  33. package/lib/MySQL/MySQLDatabase.js +294 -0
  34. package/lib/MySQL/MySQLDatabase.js.map +1 -0
  35. package/lib/MySQL/MySQLMigrations.d.ts +20 -0
  36. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
  37. package/lib/MySQL/MySQLMigrations.js +72 -0
  38. package/lib/MySQL/MySQLMigrations.js.map +1 -0
  39. package/lib/MySQL/MySQLOrigin.d.ts +15 -0
  40. package/lib/MySQL/MySQLOrigin.d.ts.map +1 -0
  41. package/lib/MySQL/MySQLOrigin.js +2 -0
  42. package/lib/MySQL/MySQLOrigin.js.map +1 -0
  43. package/lib/MySQL/MySQLPassword.d.ts +116 -0
  44. package/lib/MySQL/MySQLPassword.d.ts.map +1 -0
  45. package/lib/MySQL/MySQLPassword.js +212 -0
  46. package/lib/MySQL/MySQLPassword.js.map +1 -0
  47. package/lib/MySQL/index.d.ts +6 -0
  48. package/lib/MySQL/index.d.ts.map +1 -0
  49. package/lib/MySQL/index.js +6 -0
  50. package/lib/MySQL/index.js.map +1 -0
  51. package/lib/OAuthClient.d.ts +32 -0
  52. package/lib/OAuthClient.d.ts.map +1 -0
  53. package/lib/OAuthClient.js +52 -0
  54. package/lib/OAuthClient.js.map +1 -0
  55. package/lib/Postgres/PostgresBranch.d.ts +75 -0
  56. package/lib/Postgres/PostgresBranch.d.ts.map +1 -0
  57. package/lib/Postgres/PostgresBranch.js +15 -0
  58. package/lib/Postgres/PostgresBranch.js.map +1 -0
  59. package/lib/Postgres/PostgresClusterSize.d.ts +77 -0
  60. package/lib/Postgres/PostgresClusterSize.d.ts.map +1 -0
  61. package/lib/Postgres/PostgresClusterSize.js +89 -0
  62. package/lib/Postgres/PostgresClusterSize.js.map +1 -0
  63. package/lib/Postgres/PostgresDatabase.d.ts +95 -0
  64. package/lib/Postgres/PostgresDatabase.d.ts.map +1 -0
  65. package/lib/Postgres/PostgresDatabase.js +294 -0
  66. package/lib/Postgres/PostgresDatabase.js.map +1 -0
  67. package/lib/Postgres/PostgresDefaultRole.d.ts +94 -0
  68. package/lib/Postgres/PostgresDefaultRole.d.ts.map +1 -0
  69. package/lib/Postgres/PostgresDefaultRole.js +187 -0
  70. package/lib/Postgres/PostgresDefaultRole.js.map +1 -0
  71. package/lib/Postgres/PostgresMigrations.d.ts +16 -0
  72. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
  73. package/lib/Postgres/PostgresMigrations.js +65 -0
  74. package/lib/Postgres/PostgresMigrations.js.map +1 -0
  75. package/lib/Postgres/PostgresOrigin.d.ts +15 -0
  76. package/lib/Postgres/PostgresOrigin.d.ts.map +1 -0
  77. package/lib/Postgres/PostgresOrigin.js +2 -0
  78. package/lib/Postgres/PostgresOrigin.js.map +1 -0
  79. package/lib/Postgres/PostgresRole.d.ts +142 -0
  80. package/lib/Postgres/PostgresRole.d.ts.map +1 -0
  81. package/lib/Postgres/PostgresRole.js +268 -0
  82. package/lib/Postgres/PostgresRole.js.map +1 -0
  83. package/lib/Postgres/index.d.ts +7 -0
  84. package/lib/Postgres/index.d.ts.map +1 -0
  85. package/lib/Postgres/index.js +7 -0
  86. package/lib/Postgres/index.js.map +1 -0
  87. package/lib/Providers.d.ts +24 -0
  88. package/lib/Providers.d.ts.map +1 -0
  89. package/lib/Providers.js +40 -0
  90. package/lib/Providers.js.map +1 -0
  91. package/lib/Util.d.ts +93 -0
  92. package/lib/Util.d.ts.map +1 -0
  93. package/lib/Util.js +135 -0
  94. package/lib/Util.js.map +1 -0
  95. package/lib/index.d.ts +9 -0
  96. package/lib/index.d.ts.map +1 -0
  97. package/lib/index.js +9 -0
  98. package/lib/index.js.map +1 -0
  99. package/package.json +79 -3
  100. package/src/AuthProvider.ts +584 -0
  101. package/src/Branch.ts +657 -0
  102. package/src/Contributions.ts +9 -0
  103. package/src/Credentials.ts +92 -0
  104. package/src/Database.ts +152 -0
  105. package/src/MySQL/MySQLBranch.ts +109 -0
  106. package/src/MySQL/MySQLClusterSize.ts +185 -0
  107. package/src/MySQL/MySQLDatabase.ts +521 -0
  108. package/src/MySQL/MySQLMigrations.ts +133 -0
  109. package/src/MySQL/MySQLOrigin.ts +15 -0
  110. package/src/MySQL/MySQLPassword.ts +428 -0
  111. package/src/MySQL/index.ts +5 -0
  112. package/src/OAuthClient.ts +59 -0
  113. package/src/Postgres/PostgresBranch.ts +97 -0
  114. package/src/Postgres/PostgresClusterSize.ts +262 -0
  115. package/src/Postgres/PostgresDatabase.ts +469 -0
  116. package/src/Postgres/PostgresDefaultRole.ts +340 -0
  117. package/src/Postgres/PostgresMigrations.ts +122 -0
  118. package/src/Postgres/PostgresOrigin.ts +15 -0
  119. package/src/Postgres/PostgresRole.ts +547 -0
  120. package/src/Postgres/index.ts +6 -0
  121. package/src/Providers.ts +67 -0
  122. package/src/Util.ts +205 -0
  123. package/src/index.ts +14 -0
@@ -0,0 +1,428 @@
1
+ import * as planetscale from "@distilled.cloud/planetscale";
2
+ import { Credentials } from "@distilled.cloud/planetscale/Credentials";
3
+ import { deepEqual, isResolved } from "alchemy/Diff";
4
+ import { createPhysicalName } from "alchemy/PhysicalName";
5
+ import * as Provider from "alchemy/Provider";
6
+ import { Resource } from "alchemy/Resource";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Redacted from "effect/Redacted";
9
+ import * as Stream from "effect/Stream";
10
+ import type { Providers } from "../Providers.ts";
11
+ import type { MySQLBranch } from "./MySQLBranch.ts";
12
+ import type { MySQLDatabase } from "./MySQLDatabase.ts";
13
+ import 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
+ */
21
+ export 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
+ */
63
+ export 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
+ */
126
+ export type MySQLPassword = Resource<
127
+ "Planetscale.MySQLPassword",
128
+ MySQLPasswordProps,
129
+ MySQLPasswordAttributes,
130
+ never,
131
+ Providers
132
+ >;
133
+
134
+ /** @resource */
135
+ export const MySQLPassword = Resource<MySQLPassword>("Planetscale.MySQLPassword");
136
+
137
+ export const MySQLPasswordProvider = () =>
138
+ Provider.succeed(MySQLPassword, {
139
+ stables: ["id"],
140
+ diff: Effect.fn(function* ({ olds = {}, news }) {
141
+ if (!isResolved(news)) return undefined;
142
+
143
+ // Replace on any immutable-in-place change; engine falls through
144
+ // to reconcile for everything else (which authoritatively diffs
145
+ // against observed cloud state).
146
+ if (news.role !== olds.role) return { action: "replace" } as const;
147
+ if (news.replica !== olds.replica) {
148
+ return { action: "replace" } as const;
149
+ }
150
+ if (news.ttl !== olds.ttl) return { action: "replace" } as const;
151
+
152
+ const newDb = resolveDatabaseName(news.database);
153
+ const oldDb = olds.database ? resolveDatabaseName(olds.database) : undefined;
154
+ if (oldDb && newDb !== oldDb) {
155
+ return { action: "replace" } as const;
156
+ }
157
+ const newBranch = resolveBranchName(news.branch);
158
+ const oldBranch = resolveBranchName(olds.branch);
159
+ if (newBranch !== oldBranch) {
160
+ return { action: "replace" } as const;
161
+ }
162
+ return undefined;
163
+ }),
164
+
165
+ read: Effect.fn(function* ({ output }) {
166
+ // Adoption (no cached output) is impossible: the PlanetScale API
167
+ // never re-issues plaintext, so even if we found a matching live
168
+ // password by listing+name we couldn't deliver the `password`
169
+ // attribute. Fall through to greenfield create in `reconcile`.
170
+ if (!output) return undefined;
171
+
172
+ // Refresh — verify the cached password still exists and
173
+ // re-emit the persisted output (with plaintext intact).
174
+ // If it's gone, return undefined so the engine routes to
175
+ // reconcile, which will surface the unrecoverable-rotation
176
+ // condition explicitly.
177
+ return yield* planetscale
178
+ .getPassword({
179
+ organization: output.organization,
180
+ database: output.database,
181
+ branch: output.branch,
182
+ id: output.id,
183
+ })
184
+ .pipe(
185
+ Effect.map((password) =>
186
+ buildAttributes(password, output.password, {
187
+ organization: output.organization,
188
+ database: output.database,
189
+ branch: output.branch,
190
+ }),
191
+ ),
192
+ Effect.catchTag("NotFound", () => Effect.undefined),
193
+ );
194
+ }),
195
+
196
+ reconcile: Effect.fn(function* ({ id, news, output }) {
197
+ const newName = yield* createPasswordName(id, news.name);
198
+
199
+ // Identifiers — stable across update (diff replaces on
200
+ // database/branch changes), so we can pull from `output` first
201
+ // and fall back to `news` for greenfield.
202
+ const { organization: envOrg } = yield* yield* Credentials;
203
+ const organization = output?.organization ?? resolveDatabaseOrg(news.database) ?? envOrg;
204
+ const databaseName = output?.database ?? resolveDatabaseName(news.database);
205
+ const branchName = output?.branch ?? resolveBranchName(news.branch);
206
+
207
+ // 1. Observe — read live state. The PlanetScale API has no
208
+ // "lookup by name" path (names aren't unique within a branch)
209
+ // so the only way to find the live password is by id. Without
210
+ // a cached output we cannot observe and fall straight through
211
+ // to ensure (greenfield create).
212
+ const observed = output
213
+ ? yield* planetscale
214
+ .getPassword({
215
+ organization,
216
+ database: databaseName,
217
+ branch: branchName,
218
+ id: output.id,
219
+ })
220
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined))
221
+ : undefined;
222
+
223
+ // 2. Ensure — create the password if it doesn't exist live.
224
+ // This single branch covers greenfield (no output) AND the
225
+ // out-of-band-deletion case (output cached but cloud lost it).
226
+ // The latter is structurally a rotation: the new password
227
+ // has a fresh id/host/username/plaintext that propagate
228
+ // downstream through the dependency graph. There is no path
229
+ // to recover the original plaintext, so recreating is the
230
+ // only way to converge.
231
+ let live: planetscale.GetPasswordOutput;
232
+ let plaintext: Redacted.Redacted<string>;
233
+ if (observed) {
234
+ live = observed;
235
+ // We re-use the cached plaintext: the API never re-issues it.
236
+ plaintext = output!.password;
237
+ } else {
238
+ const created = yield* planetscale.createPassword({
239
+ organization,
240
+ database: databaseName,
241
+ branch: branchName,
242
+ name: newName,
243
+ role: news.role,
244
+ replica: news.replica,
245
+ ttl: news.ttl,
246
+ cidrs: news.cidrs,
247
+ });
248
+ plaintext = created.plain_text;
249
+ live = created;
250
+ }
251
+
252
+ // 3. Sync — diff observed cloud state against desired and patch
253
+ // only what changed. Only `name` and `cidrs` are mutable in
254
+ // place; everything else triggers a replace via diff().
255
+ // Skip the API entirely when there's no delta.
256
+ const wantsRename = newName !== live.name;
257
+ const observedCidrs = normalizeCidrs(live.cidrs);
258
+ const desiredCidrs = normalizeCidrs(news.cidrs);
259
+ const wantsCidrs = !deepEqual(desiredCidrs, observedCidrs);
260
+
261
+ if (wantsRename || wantsCidrs) {
262
+ live = yield* planetscale.updatePassword({
263
+ organization,
264
+ database: databaseName,
265
+ branch: branchName,
266
+ id: live.id,
267
+ name: wantsRename ? newName : undefined,
268
+ cidrs: wantsCidrs ? news.cidrs : undefined,
269
+ });
270
+ }
271
+
272
+ // 4. Return — fresh attrs sourced from observed cloud state,
273
+ // re-using cached plaintext when we didn't just create.
274
+ return buildAttributes(live, plaintext, {
275
+ organization,
276
+ database: databaseName,
277
+ branch: branchName,
278
+ });
279
+ }),
280
+
281
+ delete: Effect.fn(function* ({ output }) {
282
+ yield* planetscale
283
+ .deletePassword({
284
+ organization: output.organization,
285
+ database: output.database,
286
+ branch: output.branch,
287
+ id: output.id,
288
+ })
289
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
290
+ }),
291
+
292
+ list: Effect.fn(function* () {
293
+ const { organization } = yield* yield* Credentials;
294
+
295
+ const databases = yield* planetscale.listDatabases.pages({ organization }).pipe(
296
+ Stream.runCollect,
297
+ Effect.map((chunk) =>
298
+ Array.from(chunk).flatMap((page) => page.data.filter((db) => db.kind === "mysql")),
299
+ ),
300
+ );
301
+
302
+ const rows = yield* Effect.forEach(
303
+ databases,
304
+ (db) =>
305
+ planetscale.listBranches.pages({ organization, database: db.name }).pipe(
306
+ Stream.runCollect,
307
+ Effect.map((branchPages) =>
308
+ Array.from(branchPages).flatMap((page) =>
309
+ page.data.filter((branch) => branch.kind === "mysql"),
310
+ ),
311
+ ),
312
+ Effect.catchTag("NotFound", () =>
313
+ Effect.succeed([{ name: db.default_branch ?? "main" }]),
314
+ ),
315
+ Effect.flatMap((branches) =>
316
+ Effect.forEach(
317
+ branches,
318
+ (branch) =>
319
+ planetscale.listPasswords
320
+ .pages({
321
+ organization,
322
+ database: db.name,
323
+ branch: branch.name,
324
+ })
325
+ .pipe(
326
+ Stream.runCollect,
327
+ Effect.map((passwordPages): MySQLPasswordAttributes[] =>
328
+ Array.from(passwordPages).flatMap((page) =>
329
+ page.data.map((password) =>
330
+ buildAttributes(password, Redacted.make(""), {
331
+ organization,
332
+ database: db.name,
333
+ branch: branch.name,
334
+ }),
335
+ ),
336
+ ),
337
+ ),
338
+ Effect.catchTag("NotFound", () =>
339
+ Effect.succeed([] as MySQLPasswordAttributes[]),
340
+ ),
341
+ ),
342
+ { concurrency: 10 },
343
+ ).pipe(Effect.map((perBranch) => perBranch.flat())),
344
+ ),
345
+ ),
346
+ { concurrency: 10 },
347
+ );
348
+
349
+ return rows.flat();
350
+ }),
351
+ });
352
+
353
+ // At runtime, Resource references in props are resolved to their full
354
+ // attributes — so `news.database` (when a Database resource was passed)
355
+ // is `{ name: string; organization: string; ... }` rather than the
356
+ // statically-typed Resource (whose attributes are `Output<...>`). We
357
+ // cast through structural shapes here.
358
+ type DatabaseRef = string | { name: string; organization?: string };
359
+ type BranchRef = string | { name: string };
360
+
361
+ const resolveDatabaseName = (database: string | MySQLDatabase): string => {
362
+ const ref = database as unknown as DatabaseRef;
363
+ return typeof ref === "string" ? ref : ref.name;
364
+ };
365
+
366
+ const resolveDatabaseOrg = (database: string | MySQLDatabase): string | undefined => {
367
+ const ref = database as unknown as DatabaseRef;
368
+ return typeof ref === "string" ? undefined : ref.organization;
369
+ };
370
+
371
+ const resolveBranchName = (branch: string | MySQLBranch | undefined): string => {
372
+ const ref = branch as unknown as BranchRef | undefined;
373
+ return !ref ? "main" : typeof ref === "string" ? ref : ref.name;
374
+ };
375
+
376
+ const createPasswordName = (id: string, name: string | undefined) =>
377
+ Effect.gen(function* () {
378
+ return name ?? (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }));
379
+ });
380
+
381
+ // Normalize the API's nullable `cidrs` field into the `string[] | undefined`
382
+ // shape we persist so downstream comparisons line up regardless of the
383
+ // API returning `null` vs `[]` vs an actual list.
384
+ const normalizeCidrs = (
385
+ cidrs: readonly string[] | null | undefined,
386
+ ): readonly string[] | undefined => (cidrs == null || cidrs.length === 0 ? undefined : cidrs);
387
+
388
+ const buildAttributes = (
389
+ password: {
390
+ id: string;
391
+ name: string;
392
+ expires_at: string | null;
393
+ access_host_url: string;
394
+ username: string;
395
+ role: "reader" | "writer" | "admin" | "readwriter" | (string & {});
396
+ replica: boolean;
397
+ ttl_seconds: number | null;
398
+ cidrs: readonly string[] | null;
399
+ },
400
+ plaintext: Redacted.Redacted<string>,
401
+ context: {
402
+ organization: string;
403
+ database: string;
404
+ branch: string;
405
+ },
406
+ ): MySQLPasswordAttributes => ({
407
+ id: password.id,
408
+ name: password.name,
409
+ expiresAt: password.expires_at,
410
+ host: password.access_host_url,
411
+ username: password.username,
412
+ password: plaintext,
413
+ origin: {
414
+ scheme: "mysql",
415
+ host: password.access_host_url,
416
+ port: 3306,
417
+ database: context.database,
418
+ user: password.username,
419
+ password: plaintext,
420
+ },
421
+ organization: context.organization,
422
+ database: context.database,
423
+ branch: context.branch,
424
+ role: password.role,
425
+ replica: password.replica,
426
+ ttl: password.ttl_seconds ?? undefined,
427
+ cidrs: normalizeCidrs(password.cidrs),
428
+ });
@@ -0,0 +1,5 @@
1
+ export * from "./MySQLClusterSize.ts";
2
+ export * from "./MySQLBranch.ts";
3
+ export * from "./MySQLDatabase.ts";
4
+ export * from "./MySQLOrigin.ts";
5
+ export * from "./MySQLPassword.ts";
@@ -0,0 +1,59 @@
1
+ import { makeOAuthClient } from "alchemy/Auth/OAuthFlow";
2
+ import * as Redacted from "effect/Redacted";
3
+
4
+ export { OAuthCredentials, OAuthError, type Authorization } from "alchemy/Auth/OAuthFlow";
5
+
6
+ /**
7
+ * Registered PlanetScale OAuth application credentials.
8
+ *
9
+ * Unlike Cloudflare, PlanetScale OAuth has **no public-client flow** — the
10
+ * token endpoint requires client authentication for every grant (PKCE is
11
+ * advertised in their discovery doc but does not lift that requirement), so
12
+ * exchanging the authorization code (and refreshing the token) requires the
13
+ * application's `client_secret`. There is no way to keep that secret out of
14
+ * a distributed CLI, so it ships here: the exposure is the same posture as a
15
+ * public `client_id` (a stolen refresh token is usable, exactly like
16
+ * Cloudflare's secret-less refresh), and it can be rotated by cutting a new
17
+ * release. PlanetScale's own CLI ships its OAuth `client_secret` in source
18
+ * the same way:
19
+ * https://github.com/planetscale/cli/blob/main/internal/auth/authenticator.go
20
+ *
21
+ * Registered at https://app.planetscale.com with redirect URI
22
+ * {@link OAUTH_REDIRECT_URI}. Scopes are configured on the application
23
+ * itself, not requested per-authorization (so `authorize()` is called with
24
+ * no scopes). Rotate by registering a new secret and cutting a release.
25
+ */
26
+ export const OAUTH_CLIENT_ID = "pscale_app_aa12e3938baebb788aac443f66e422da";
27
+ export const OAUTH_CLIENT_SECRET = Redacted.make(
28
+ "pscale_app_secret_yyZ3Q8oe99GP9_yA5wrA5er6RuN6Lz9dC66Bj1OJzpg",
29
+ );
30
+
31
+ export const OAUTH_REDIRECT_URI = "https://alchemy.run/auth/callback";
32
+ export const OAUTH_LOCAL_CALLBACK_URI = "http://localhost:9976/auth/callback";
33
+ export const OAUTH_ENDPOINTS = {
34
+ // PlanetScale's own .well-known OAuth discovery doc declares this as
35
+ // the authorization_endpoint — NOT auth.planetscale.com/oauth/authorize
36
+ // (which their public docs cite). The auth.planetscale.com alias does
37
+ // render a consent screen but emits codes whose resulting tokens lack
38
+ // a `sub` claim, so the resource API at api.planetscale.com rejects
39
+ // them as invalid. Use the canonical endpoint.
40
+ authorize: "https://app.planetscale.com/oauth/authorize",
41
+ token: "https://auth.planetscale.com/oauth/token",
42
+ };
43
+
44
+ const client = makeOAuthClient({
45
+ clientId: OAUTH_CLIENT_ID,
46
+ endpoints: OAUTH_ENDPOINTS,
47
+ redirectUri: OAUTH_REDIRECT_URI,
48
+ localCallbackUri: OAUTH_LOCAL_CALLBACK_URI,
49
+ auth: { kind: "clientSecret", clientSecret: OAUTH_CLIENT_SECRET },
50
+ // PlanetScale's docs show the token endpoint with all parameters in the
51
+ // query string (https://planetscale.com/docs/api/reference/oauth). Their
52
+ // .well-known discovery doc advertises client_secret_basic /
53
+ // client_secret_post instead, but both behave identically to the
54
+ // query-string form in practice, so we follow the public docs literally.
55
+ tokenTransport: "query",
56
+ });
57
+
58
+ export const { authorize, callback, exchange, exchangeCallbackInput, refresh, usesCurrentClient } =
59
+ client;
@@ -0,0 +1,97 @@
1
+ import { Resource } from "alchemy/Resource";
2
+ import { type BaseBranchAttributes, type BaseBranchProps, makeBranchProvider } from "../Branch.ts";
3
+ import type { Providers } from "../Providers.ts";
4
+ import type { PostgresClusterSize } from "./PostgresClusterSize.ts";
5
+ import type { PostgresDatabase } from "./PostgresDatabase.ts";
6
+ import { runPostgresImports, runPostgresMigrations } from "./PostgresMigrations.ts";
7
+
8
+ /**
9
+ * Properties for creating or updating a {@link PostgresBranch}.
10
+ */
11
+ export interface PostgresBranchProps extends BaseBranchProps {
12
+ /**
13
+ * The Postgres database. Either a string database name or a
14
+ * {@link PostgresDatabase} resource.
15
+ */
16
+ database: string | PostgresDatabase;
17
+
18
+ /**
19
+ * PostgreSQL cluster size for the branch. Required if `backupId` is provided.
20
+ * Short NAS sizes are expanded using the parent branch's region and CPU
21
+ * architecture (e.g. `"PS_DEV"` off an ARM parent becomes `"PS_DEV_AWS_ARM"`). Metal
22
+ * requires the full SKU (e.g. `"M1_10_AWS_ARM_D_METAL_10"`).
23
+ */
24
+ clusterSize?: PostgresClusterSize;
25
+
26
+ /**
27
+ * Total number of replicas for the branch. `0` creates or converges the
28
+ * branch to non-HA/single-node; `2+` enables HA.
29
+ */
30
+ replicas?: number;
31
+
32
+ /**
33
+ * Parent branch — either a string name or another {@link PostgresBranch}.
34
+ * @default "main"
35
+ */
36
+ parentBranch?: string | PostgresBranch;
37
+ }
38
+
39
+ /**
40
+ * Output attributes of a deployed {@link PostgresBranch}.
41
+ */
42
+ export interface PostgresBranchAttributes extends BaseBranchAttributes {}
43
+
44
+ /**
45
+ * A PlanetScale branch of a {@link PostgresDatabase}. For MySQL branches
46
+ * use {@link MySQLBranch} instead.
47
+ *
48
+ * ### Creating a Branch
49
+ * **Example:** Branch from main
50
+ * ```typescript
51
+ * const branch = yield* Planetscale.PostgresBranch("Feature123", {
52
+ * database: "my-db",
53
+ * parentBranch: "main",
54
+ * });
55
+ * ```
56
+ *
57
+ * **Example:** Branch from a PostgresDatabase resource
58
+ * ```typescript
59
+ * const db = yield* Planetscale.PostgresDatabase("MyDb", { clusterSize: "PS_10" });
60
+ * const branch = yield* Planetscale.PostgresBranch("Feature456", {
61
+ * database: db,
62
+ * parentBranch: "main",
63
+ * });
64
+ * ```
65
+ *
66
+ * ### Migrations and seed data
67
+ * **Example:** Apply migrations on a branch
68
+ * ```typescript
69
+ * const branch = yield* Planetscale.PostgresBranch("Feature123", {
70
+ * database: db,
71
+ * parentBranch: "main",
72
+ * migrationsDir: "./migrations",
73
+ * importFiles: ["./seed.sql"],
74
+ * });
75
+ * ```
76
+ */
77
+ export type PostgresBranch = Resource<
78
+ "Planetscale.PostgresBranch",
79
+ PostgresBranchProps,
80
+ PostgresBranchAttributes,
81
+ never,
82
+ Providers
83
+ >;
84
+
85
+ /** @resource */
86
+ export const PostgresBranch = Resource<PostgresBranch>("Planetscale.PostgresBranch");
87
+
88
+ export const PostgresBranchProvider = () =>
89
+ makeBranchProvider({
90
+ resource: PostgresBranch,
91
+ expectedKind: "postgresql",
92
+ engineLabel: "PostgresBranch",
93
+ runners: {
94
+ runMigrations: runPostgresMigrations,
95
+ runImports: runPostgresImports,
96
+ },
97
+ });