@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,340 @@
1
+ import * as planetscale from "@distilled.cloud/planetscale";
2
+ import { Credentials } from "@distilled.cloud/planetscale/Credentials";
3
+ import { isResolved } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Redacted from "effect/Redacted";
8
+ import * as Stream from "effect/Stream";
9
+ import type { Providers } from "../Providers.ts";
10
+ import { PlanetscaleConflict, waitForBranchReady } from "../Util.ts";
11
+ import type { PostgresBranch } from "./PostgresBranch.ts";
12
+ import type { PostgresDatabase } from "./PostgresDatabase.ts";
13
+ import 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
+ */
22
+ export 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
+ */
46
+ export 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
+ */
100
+ export type PostgresDefaultRole = Resource<
101
+ "Planetscale.PostgresDefaultRole",
102
+ PostgresDefaultRoleProps,
103
+ PostgresDefaultRoleAttributes,
104
+ never,
105
+ Providers
106
+ >;
107
+
108
+ /** @resource */
109
+ export const PostgresDefaultRole = Resource<PostgresDefaultRole>("Planetscale.PostgresDefaultRole");
110
+
111
+ export const PostgresDefaultRoleProvider = () =>
112
+ Provider.succeed(PostgresDefaultRole, {
113
+ stables: ["id"],
114
+ diff: Effect.fn(function* ({ news, olds = {} }) {
115
+ if (!isResolved(news)) return undefined;
116
+
117
+ const newDb = resolveDatabaseName(news.database);
118
+ const oldDb = olds.database ? resolveDatabaseName(olds.database) : undefined;
119
+ if (oldDb && newDb !== oldDb) {
120
+ return { action: "replace" } as const;
121
+ }
122
+ const newBranch = resolveBranchName(news.branch);
123
+ const oldBranch = resolveBranchName(olds.branch);
124
+ if (newBranch !== oldBranch) {
125
+ return { action: "replace" } as const;
126
+ }
127
+ return undefined;
128
+ }),
129
+
130
+ read: Effect.fn(function* ({ output }) {
131
+ // Adoption (no cached output) is impossible without destroying
132
+ // existing credentials. The engine routes adoption through
133
+ // `read`, but here we always defer to reconcile so the
134
+ // `forceReset` props flag remains the user-facing switch for
135
+ // takeover. Returning undefined makes the engine treat the
136
+ // resource as greenfield; reconcile then observes live state
137
+ // and either fails (no forceReset) or resets (forceReset).
138
+ if (!output) return undefined;
139
+
140
+ // Refresh — verify the default role still exists and re-emit
141
+ // the persisted output (with plaintext intact). If gone,
142
+ // return undefined so the engine routes through reconcile,
143
+ // which will recreate via reset.
144
+ return yield* planetscale
145
+ .getDefaultRole({
146
+ organization: output.organization,
147
+ database: output.database,
148
+ branch: output.branch,
149
+ })
150
+ .pipe(
151
+ Effect.map((role) => ({
152
+ ...output,
153
+ privateHost: role.private_access_host_url,
154
+ privateConnectionServiceName: role.private_connection_service_name,
155
+ })),
156
+ Effect.catchTag("NotFound", () => Effect.undefined),
157
+ );
158
+ }),
159
+
160
+ reconcile: Effect.fn(function* ({ news, output }) {
161
+ const { organization: envOrg } = yield* yield* Credentials;
162
+ const organization = resolveDatabaseOrg(news.database) ?? envOrg;
163
+ const databaseName = resolveDatabaseName(news.database);
164
+ const branchName = resolveBranchName(news.branch);
165
+
166
+ // 1. Observe — the default role is the singleton for this
167
+ // (database, branch); we key on those rather than an id.
168
+ // Observation runs unconditionally so the adoption guard
169
+ // below fires on greenfield deploys against branches that
170
+ // already have a default role (otherwise we would silently
171
+ // `resetDefault` and destroy their credentials).
172
+ const observed = yield* planetscale
173
+ .getDefaultRole({
174
+ organization,
175
+ database: databaseName,
176
+ branch: branchName,
177
+ })
178
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined));
179
+
180
+ // 2. Sync (no-op) — we own the role and it still exists in the
181
+ // cloud. Default roles have nothing mutable in place: their
182
+ // plaintext password isn't retrievable, so we retain the cached
183
+ // credentials while refreshing the private connection details.
184
+ // diff() forces a replace when database/branch change.
185
+ if (output && observed) {
186
+ return {
187
+ ...output,
188
+ privateHost: observed.private_access_host_url,
189
+ privateConnectionServiceName: observed.private_connection_service_name,
190
+ };
191
+ }
192
+
193
+ // 3. Adoption guard — observed exists but we have no prior
194
+ // output (greenfield against a pre-existing default role).
195
+ // Taking over requires a reset (which destroys the existing
196
+ // password), so gate on an explicit forceReset opt-in.
197
+ if (observed && !news.forceReset) {
198
+ return yield* new PlanetscaleConflict({
199
+ message: `Default role already exists for database "${databaseName}" branch "${branchName}". Use forceReset: true to reset.`,
200
+ });
201
+ }
202
+
203
+ // 4. Ensure — (re)create the default role. This branch runs
204
+ // for greenfield (no output, no observed), drift recovery
205
+ // (output cached but cloud state gone), and authorised
206
+ // adoption (observed present + forceReset).
207
+ yield* waitForBranchReady(organization, databaseName, branchName);
208
+ const data = yield* planetscale.resetDefaultRole({
209
+ organization,
210
+ database: databaseName,
211
+ branch: branchName,
212
+ });
213
+
214
+ if (!data.password) {
215
+ return yield* Effect.die(`Planetscale did not return a password for Default Role.`);
216
+ }
217
+
218
+ const password = Redacted.isRedacted(data.password)
219
+ ? Redacted.value(data.password)
220
+ : data.password;
221
+
222
+ const connectionUrl = `postgresql://${data.username}:${password}@${data.access_host_url}:5432/${data.database_name}?sslmode=verify-full`;
223
+ const connectionUrlPooled = `postgresql://${data.username}:${password}@${data.access_host_url}:6432/${data.database_name}?sslmode=verify-full`;
224
+
225
+ return {
226
+ id: data.id,
227
+ name: data.name,
228
+ expiresAt: data.expires_at,
229
+ host: data.access_host_url,
230
+ username: data.username,
231
+ password: Redacted.make(password),
232
+ ttl: data.ttl,
233
+ databaseName: data.database_name,
234
+ connectionUrl: Redacted.make(connectionUrl),
235
+ connectionUrlPooled: Redacted.make(connectionUrlPooled),
236
+ privateHost: data.private_access_host_url,
237
+ privateConnectionServiceName: data.private_connection_service_name,
238
+ inheritedRoles: data.inherited_roles as InheritedRole[],
239
+ organization,
240
+ database: databaseName,
241
+ branch: branchName,
242
+ } satisfies PostgresDefaultRoleAttributes;
243
+ }),
244
+
245
+ delete: Effect.fn(function* ({ output }) {
246
+ // No delete endpoint — reset to invalidate existing credentials.
247
+ yield* planetscale
248
+ .resetDefaultRole({
249
+ organization: output.organization,
250
+ database: output.database,
251
+ branch: output.branch,
252
+ })
253
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
254
+ }),
255
+
256
+ list: Effect.fn(function* () {
257
+ const { organization } = yield* yield* Credentials;
258
+
259
+ const roles = yield* planetscale.listDatabases.pages({ organization }).pipe(
260
+ Stream.map((page) => page.data.filter((db) => db.kind === "postgresql")),
261
+ Stream.flattenIterable,
262
+ Stream.flatMap(
263
+ (db) =>
264
+ planetscale.listBranches.pages({ organization, database: db.name }).pipe(
265
+ Stream.map((page) => page.data.filter((branch) => branch.kind === "postgresql")),
266
+ Stream.flattenIterable,
267
+ Stream.catchTag("NotFound", () =>
268
+ Stream.succeed({ name: db.default_branch ?? "main" }),
269
+ ),
270
+ Stream.flatMap(
271
+ (branch) =>
272
+ planetscale.listRoles
273
+ .pages({
274
+ organization,
275
+ database: db.name,
276
+ branch: branch.name,
277
+ })
278
+ .pipe(
279
+ Stream.map((page) =>
280
+ page.data
281
+ .filter((role) => role.default)
282
+ .map(
283
+ (role) =>
284
+ ({
285
+ id: role.id,
286
+ name: role.name,
287
+ expiresAt: role.expires_at,
288
+ host: role.access_host_url,
289
+ username: role.username,
290
+ password: Redacted.make(""),
291
+ ttl: role.ttl,
292
+ databaseName: role.database_name,
293
+ connectionUrl: Redacted.make(""),
294
+ connectionUrlPooled: Redacted.make(""),
295
+ privateHost: role.private_access_host_url,
296
+ privateConnectionServiceName: role.private_connection_service_name,
297
+ inheritedRoles: role.inherited_roles as InheritedRole[],
298
+ organization,
299
+ database: db.name,
300
+ branch: branch.name,
301
+ }) satisfies PostgresDefaultRoleAttributes as PostgresDefaultRoleAttributes,
302
+ ),
303
+ ),
304
+ Stream.flattenIterable,
305
+ Stream.catchTags({
306
+ NotFound: () => Stream.empty,
307
+ Forbidden: () => Stream.empty,
308
+ }),
309
+ ),
310
+ { concurrency: 10 },
311
+ ),
312
+ ),
313
+ { concurrency: 10 },
314
+ ),
315
+ Stream.runCollect,
316
+ );
317
+
318
+ return Array.from(roles);
319
+ }),
320
+ });
321
+
322
+ // Structural shapes for runtime-resolved Resource references — see
323
+ // notes in Password.ts.
324
+ type DatabaseRef = string | { name: string; organization?: string };
325
+ type BranchRef = string | { name: string };
326
+
327
+ const resolveDatabaseName = (database: string | PostgresDatabase): string => {
328
+ const ref = database as unknown as DatabaseRef;
329
+ return typeof ref === "string" ? ref : ref.name;
330
+ };
331
+
332
+ const resolveDatabaseOrg = (database: string | PostgresDatabase): string | undefined => {
333
+ const ref = database as unknown as DatabaseRef;
334
+ return typeof ref === "string" ? undefined : ref.organization;
335
+ };
336
+
337
+ const resolveBranchName = (branch: string | PostgresBranch | undefined): string => {
338
+ const ref = branch as unknown as BranchRef | undefined;
339
+ return !ref ? "main" : typeof ref === "string" ? ref : ref.name;
340
+ };
@@ -0,0 +1,122 @@
1
+ import {
2
+ runMigrations,
3
+ withPgExecutor,
4
+ type NormalizedMigrationsInput,
5
+ type SqlExecutor,
6
+ type StampedMigrationsState,
7
+ } from "@alchemy.run/sql/Migrations";
8
+ import { stripSslQueryParams } from "@alchemy.run/sql/PostgresDriver";
9
+ import { applySqlImports } from "@alchemy.run/sql/SqlFile";
10
+ import * as ps from "@distilled.cloud/planetscale";
11
+ import * as Effect from "effect/Effect";
12
+ import * as Redacted from "effect/Redacted";
13
+ import * as Schedule from "effect/Schedule";
14
+
15
+ const MIGRATION_ROLE_TTL_SECONDS = 600;
16
+
17
+ export interface PostgresMigrationTarget {
18
+ organization: string;
19
+ database: string;
20
+ branch: string;
21
+ }
22
+
23
+ /**
24
+ * PlanetScale Postgres's migration adaptation is exactly this: the shared
25
+ * pipeline with a temp-role-scoped pg client as its executor.
26
+ */
27
+ export const runPostgresMigrations = (
28
+ target: PostgresMigrationTarget,
29
+ input: NormalizedMigrationsInput,
30
+ stamped: StampedMigrationsState,
31
+ ) =>
32
+ runMigrations({
33
+ input,
34
+ stamped,
35
+ withExecutor: (apply) => withPostgresExecutor(target, apply),
36
+ });
37
+
38
+ /** Apply `importFiles`, each once and again only when it changes. */
39
+ export const runPostgresImports = (
40
+ target: PostgresMigrationTarget,
41
+ importFiles: ReadonlyArray<string>,
42
+ rootDir: string,
43
+ previous: Record<string, string>,
44
+ ) =>
45
+ applySqlImports({
46
+ importFiles,
47
+ rootDir,
48
+ previous,
49
+ apply: (file) =>
50
+ withPostgresExecutor(target, (executor) => executor.query(file.sql)).pipe(Effect.asVoid),
51
+ });
52
+
53
+ /** A verified-TLS `pg` session as a short-lived migration role. */
54
+ const withPostgresExecutor = <A, E, R>(
55
+ target: PostgresMigrationTarget,
56
+ use: (executor: SqlExecutor) => Effect.Effect<A, E, R>,
57
+ ) =>
58
+ withTemporaryPostgresRole(target, (role) =>
59
+ withPgExecutor(
60
+ {
61
+ connectionString: role.connectionUrl.pipe(Redacted.value, stripSslQueryParams),
62
+ ssl: { rejectUnauthorized: true },
63
+ },
64
+ use,
65
+ ),
66
+ );
67
+
68
+ const withTemporaryPostgresRole = <A, E, R>(
69
+ target: PostgresMigrationTarget,
70
+ use: (role: { id: string; connectionUrl: Redacted.Redacted<string> }) => Effect.Effect<A, E, R>,
71
+ ) =>
72
+ Effect.acquireUseRelease(
73
+ Effect.gen(function* () {
74
+ const created = yield* ps.createRole({
75
+ organization: target.organization,
76
+ database: target.database,
77
+ branch: target.branch,
78
+ ttl: MIGRATION_ROLE_TTL_SECONDS,
79
+ inherited_roles: ["postgres"],
80
+ });
81
+
82
+ const password = created.password!;
83
+ const value = Redacted.value(password);
84
+ const connectionUrl = yield* Effect.sync(
85
+ () =>
86
+ `postgresql://${encodeURIComponent(created.username)}:${encodeURIComponent(value)}@${created.access_host_url}:5432/${created.database_name}?sslmode=verify-full`,
87
+ );
88
+ return {
89
+ id: created.id,
90
+ connectionUrl: Redacted.make(connectionUrl),
91
+ };
92
+ }),
93
+ use,
94
+ (role) =>
95
+ ps
96
+ .deleteRole({
97
+ organization: target.organization,
98
+ database: target.database,
99
+ branch: target.branch,
100
+ id: role.id,
101
+ successor: "postgres",
102
+ })
103
+ .pipe(
104
+ // Already-deleted roles are a success: nothing to clean up.
105
+ Effect.catchTag("NotFound", () => Effect.void),
106
+ Effect.retry({
107
+ schedule: Schedule.max([Schedule.exponential("500 millis"), Schedule.recurs(5)]),
108
+ }),
109
+ // Migrations succeeded; don't fail the parent over a release-step
110
+ // hiccup. The role's TTL bounds the orphan window; log loudly so
111
+ // an operator can clean it up manually if needed.
112
+ Effect.catch((cause: unknown) =>
113
+ Effect.logWarning(
114
+ `Failed to delete temporary Planetscale migration role after retries. ` +
115
+ `It will expire via TTL (~${MIGRATION_ROLE_TTL_SECONDS}s). ` +
116
+ `organization=${target.organization} database=${target.database} ` +
117
+ `branch=${target.branch} id=${role.id}`,
118
+ cause,
119
+ ),
120
+ ),
121
+ ),
122
+ );
@@ -0,0 +1,15 @@
1
+ import * as Redacted from "effect/Redacted";
2
+
3
+ /**
4
+ * The shape `Cloudflare.Hyperdrive` and other Postgres consumers accept as an
5
+ * origin. Materialized on `Planetscale.PostgresRole` so callers can wire role
6
+ * credentials into Hyperdrive directly.
7
+ */
8
+ export type PostgresOrigin = {
9
+ scheme: "postgres" | "postgresql";
10
+ host: string;
11
+ port: number;
12
+ database: string;
13
+ user: string;
14
+ password: Redacted.Redacted<string>;
15
+ };