@alchemy.run/planetscale 0.0.0-stage → 2.0.0-beta.84

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 +87 -2
  3. package/lib/AuthProvider.d.ts +64 -0
  4. package/lib/AuthProvider.d.ts.map +1 -0
  5. package/lib/AuthProvider.js +384 -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 +65 -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 +24 -0
  36. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
  37. package/lib/MySQL/MySQLMigrations.js +106 -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 +24 -0
  72. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
  73. package/lib/Postgres/PostgresMigrations.js +116 -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 +94 -3
  100. package/src/AuthProvider.ts +571 -0
  101. package/src/Branch.ts +657 -0
  102. package/src/Contributions.ts +9 -0
  103. package/src/Credentials.ts +116 -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 +182 -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 +193 -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.succeed(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.succeed(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,193 @@
1
+ import {
2
+ makePgMigrationExecutor,
3
+ runMigrations,
4
+ type NormalizedMigrationsInput,
5
+ type StampedMigrationsState,
6
+ } from "@alchemy.run/sql/Migrations";
7
+ import { readSqlFile } from "@alchemy.run/sql/SqlFile";
8
+ import * as ps from "@distilled.cloud/planetscale";
9
+ import * as Data from "effect/Data";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Redacted from "effect/Redacted";
12
+ import * as Schedule from "effect/Schedule";
13
+ import type { Client } from "pg";
14
+
15
+ // `pg` is an optional peer dependency — loaded lazily so importing the
16
+ // Planetscale provider never requires the driver unless migrations run.
17
+ const importPg = () =>
18
+ import("pg").catch((cause) => {
19
+ throw new Error(
20
+ "Failed to load the 'pg' driver. Install the optional peer dependency 'pg' to run Planetscale Postgres migrations.",
21
+ { cause },
22
+ );
23
+ });
24
+
25
+ const MIGRATION_ROLE_TTL_SECONDS = 600;
26
+
27
+ export class PostgresMigrationError extends Data.TaggedError(
28
+ "Planetscale::PostgresMigrationError",
29
+ )<{
30
+ message: string;
31
+ cause?: unknown;
32
+ }> {}
33
+
34
+ export interface PostgresMigrationTarget {
35
+ organization: string;
36
+ database: string;
37
+ branch: string;
38
+ }
39
+
40
+ /**
41
+ * PlanetScale Postgres's migration adaptation is exactly this: the shared
42
+ * pipeline with a temp-role-scoped pg client as its executor.
43
+ */
44
+ export const runPostgresMigrations = (
45
+ target: PostgresMigrationTarget,
46
+ input: NormalizedMigrationsInput,
47
+ stamped: StampedMigrationsState,
48
+ ) =>
49
+ runMigrations({
50
+ input,
51
+ stamped,
52
+ withExecutor: (apply) =>
53
+ withPostgresClient(target, (client) => apply(makePgMigrationExecutor(client))),
54
+ });
55
+
56
+ export const runPostgresImports = (
57
+ target: PostgresMigrationTarget,
58
+ importFiles: ReadonlyArray<string>,
59
+ rootDir: string,
60
+ previous: Record<string, string>,
61
+ ) =>
62
+ Effect.gen(function* () {
63
+ const hashes: Record<string, string> = { ...previous };
64
+ for (const filePath of importFiles) {
65
+ const file = yield* readSqlFile(rootDir, filePath);
66
+ if (previous[filePath] === file.hash) {
67
+ hashes[filePath] = file.hash;
68
+ continue;
69
+ }
70
+ yield* runPostgresSql(target, file.sql);
71
+ hashes[filePath] = file.hash;
72
+ }
73
+ const tracked = new Set(importFiles);
74
+ for (const key of Object.keys(hashes)) {
75
+ if (!tracked.has(key)) delete hashes[key];
76
+ }
77
+ return hashes;
78
+ });
79
+
80
+ const runPostgresSql = (target: PostgresMigrationTarget, sql: string) =>
81
+ withPostgresClient(target, (client) => pgExec(client, sql));
82
+
83
+ const withPostgresClient = <A, E, R>(
84
+ target: PostgresMigrationTarget,
85
+ use: (client: Client) => Effect.Effect<A, E, R>,
86
+ ) =>
87
+ withTemporaryPostgresRole(target, (role) =>
88
+ Effect.acquireUseRelease(
89
+ Effect.gen(function* () {
90
+ const { Client } = yield* Effect.tryPromise({
91
+ try: importPg,
92
+ catch: toMigrationError,
93
+ });
94
+ const client = yield* Effect.sync(
95
+ () =>
96
+ new Client({
97
+ connectionString: role.connectionUrl.pipe(Redacted.value, stripPgSslQueryParams),
98
+ ssl: { rejectUnauthorized: true },
99
+ }),
100
+ );
101
+ yield* Effect.tryPromise({
102
+ try: () => client.connect(),
103
+ catch: toMigrationError,
104
+ });
105
+ return client;
106
+ }),
107
+ use,
108
+ (client) =>
109
+ Effect.tryPromise({
110
+ try: () => client.end(),
111
+ catch: toMigrationError,
112
+ }).pipe(Effect.ignore),
113
+ ),
114
+ );
115
+
116
+ const withTemporaryPostgresRole = <A, E, R>(
117
+ target: PostgresMigrationTarget,
118
+ use: (role: { id: string; connectionUrl: Redacted.Redacted<string> }) => Effect.Effect<A, E, R>,
119
+ ) =>
120
+ Effect.acquireUseRelease(
121
+ Effect.gen(function* () {
122
+ const created = yield* ps.createRole({
123
+ organization: target.organization,
124
+ database: target.database,
125
+ branch: target.branch,
126
+ ttl: MIGRATION_ROLE_TTL_SECONDS,
127
+ inherited_roles: ["postgres"],
128
+ });
129
+
130
+ const password = created.password!;
131
+ const value = Redacted.value(password);
132
+ const connectionUrl = yield* Effect.sync(
133
+ () =>
134
+ `postgresql://${encodeURIComponent(created.username)}:${encodeURIComponent(value)}@${created.access_host_url}:5432/${created.database_name}?sslmode=verify-full`,
135
+ );
136
+ return {
137
+ id: created.id,
138
+ connectionUrl: Redacted.make(connectionUrl),
139
+ };
140
+ }),
141
+ use,
142
+ (role) =>
143
+ ps
144
+ .deleteRole({
145
+ organization: target.organization,
146
+ database: target.database,
147
+ branch: target.branch,
148
+ id: role.id,
149
+ successor: "postgres",
150
+ })
151
+ .pipe(
152
+ // Already-deleted roles are a success: nothing to clean up.
153
+ Effect.catchTag("NotFound", () => Effect.void),
154
+ Effect.retry({
155
+ schedule: Schedule.max([Schedule.exponential("500 millis"), Schedule.recurs(5)]),
156
+ }),
157
+ // Migrations succeeded; don't fail the parent over a release-step
158
+ // hiccup. The role's TTL bounds the orphan window; log loudly so
159
+ // an operator can clean it up manually if needed.
160
+ Effect.catch((cause: unknown) =>
161
+ Effect.logWarning(
162
+ `Failed to delete temporary Planetscale migration role after retries. ` +
163
+ `It will expire via TTL (~${MIGRATION_ROLE_TTL_SECONDS}s). ` +
164
+ `organization=${target.organization} database=${target.database} ` +
165
+ `branch=${target.branch} id=${role.id}`,
166
+ cause,
167
+ ),
168
+ ),
169
+ ),
170
+ );
171
+
172
+ const pgExec = (client: Client, sql: string, values?: ReadonlyArray<unknown>) =>
173
+ Effect.tryPromise({
174
+ try: () => client.query(sql, values as Array<unknown>).then(() => undefined),
175
+ catch: toMigrationError,
176
+ });
177
+
178
+ const toMigrationError = (cause: unknown) =>
179
+ new PostgresMigrationError({
180
+ message: cause instanceof Error ? cause.message : String(cause),
181
+ cause,
182
+ });
183
+
184
+ const stripPgSslQueryParams = (uri: string): string => {
185
+ try {
186
+ const url = new URL(uri);
187
+ url.searchParams.delete("sslmode");
188
+ url.searchParams.delete("channel_binding");
189
+ return url.toString();
190
+ } catch {
191
+ return uri;
192
+ }
193
+ };
@@ -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
+ };