| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Redacted from "effect/Redacted"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import { |
| 5 | type GetDatabaseResponse, |
| 6 | getDatabase, |
| 7 | createConnectionRotate, |
| 8 | } from "@distilled.cloud/prisma/management"; |
| 9 | import { Retry } from "@distilled.cloud/prisma"; |
| 10 | import { extractConnectionSecrets } from "../Client.ts"; |
| 11 | import { parsePostgresOrigin, type PostgresOrigin } from "../PostgresOrigin.ts"; |
| 12 | import type { PrismaSecretConnection } from "../Types.ts"; |
| 13 | import type { ObservedDatabase } from "./Observed.ts"; |
| 14 | |
| 15 | export const hasCanonicalConnectionSecrets = ( |
| 16 | secrets: PrismaSecretConnection, |
| 17 | ) => |
| 18 | secrets.directConnectionString !== undefined || |
| 19 | secrets.pooledConnectionString !== undefined || |
| 20 | secrets.accelerateConnectionString !== undefined; |
| 21 | |
| 22 | /** |
| 23 | * Derive the conventional `databaseUrl` and parsed `origin` / `pooledOrigin` |
| 24 | * attributes from a connection's secret strings. Shared between the live |
| 25 | * Connection provider and the dev provider so local `@prisma/dev` |
| 26 | * connections materialize the same shape. |
| 27 | */ |
| 28 | export const deriveConnectionAttrs = (secrets: { |
| 29 | directConnectionString?: Redacted.Redacted<string> | undefined; |
| 30 | pooledConnectionString?: Redacted.Redacted<string> | undefined; |
| 31 | accelerateConnectionString?: Redacted.Redacted<string> | undefined; |
| 32 | }): { |
| 33 | databaseUrl: Redacted.Redacted<string> | undefined; |
| 34 | origin: PostgresOrigin | undefined; |
| 35 | pooledOrigin: PostgresOrigin | undefined; |
| 36 | } => ({ |
| 37 | databaseUrl: |
| 38 | secrets.pooledConnectionString ?? |
| 39 | secrets.directConnectionString ?? |
| 40 | secrets.accelerateConnectionString, |
| 41 | origin: secrets.directConnectionString |
| 42 | ? parsePostgresOrigin(Redacted.value(secrets.directConnectionString)) |
| 43 | : undefined, |
| 44 | pooledOrigin: secrets.pooledConnectionString |
| 45 | ? parsePostgresOrigin(Redacted.value(secrets.pooledConnectionString)) |
| 46 | : undefined, |
| 47 | }); |
| 48 | |
| 49 | export const mergeConnectionSecrets = ( |
| 50 | preferred: PrismaSecretConnection, |
| 51 | fallback: PrismaSecretConnection, |
| 52 | ): PrismaSecretConnection => ({ |
| 53 | directConnectionString: |
| 54 | preferred.directConnectionString ?? fallback.directConnectionString, |
| 55 | pooledConnectionString: |
| 56 | preferred.pooledConnectionString ?? fallback.pooledConnectionString, |
| 57 | accelerateConnectionString: |
| 58 | preferred.accelerateConnectionString ?? fallback.accelerateConnectionString, |
| 59 | host: preferred.host ?? fallback.host, |
| 60 | user: preferred.user ?? fallback.user, |
| 61 | password: preferred.password ?? fallback.password, |
| 62 | }); |
| 63 | |
| 64 | class DatabaseCredentialsNotReady extends Error {} |
| 65 | |
| 66 | const databaseCredentialsSchedule = Schedule.max([ |
| 67 | Schedule.exponential("250 millis"), |
| 68 | Schedule.recurs(6), |
| 69 | ]); |
| 70 | |
| 71 | const waitForRotatableDatabase = (database: ObservedDatabase) => |
| 72 | getDatabase({ databaseId: database.id }).pipe( |
| 73 | Effect.map((response) => response.data), |
| 74 | Effect.catchTag("NotFound", () => |
| 75 | Effect.fail( |
| 76 | new DatabaseCredentialsNotReady( |
| 77 | `Prisma database '${database.name}' (${database.id}) is not visible yet while waiting to recover its credentials.`, |
| 78 | ), |
| 79 | ), |
| 80 | ), |
| 81 | Effect.flatMap((observed) => |
| 82 | observed.status === "failure" |
| 83 | ? Effect.fail( |
| 84 | new Error( |
| 85 | `Prisma database '${observed.name}' (${observed.id}) entered terminal status 'failure' before credentials could be recovered.`, |
| 86 | ), |
| 87 | ) |
| 88 | : observed.status === "ready" && observed.defaultConnectionId !== null |
| 89 | ? Effect.succeed(observed) |
| 90 | : Effect.fail( |
| 91 | new DatabaseCredentialsNotReady( |
| 92 | `Prisma database '${observed.name}' (${observed.id}) is '${observed.status}' with defaultConnectionId '${observed.defaultConnectionId ?? "null"}'; waiting for a ready default connection before recovering credentials.`, |
| 93 | ), |
| 94 | ), |
| 95 | ), |
| 96 | Effect.retry({ |
| 97 | while: (error) => error instanceof DatabaseCredentialsNotReady, |
| 98 | schedule: databaseCredentialsSchedule, |
| 99 | }), |
| 100 | ); |
| 101 | |
| 102 | /** |
| 103 | * Recover one-time database credentials after create success but before state |
| 104 | * persistence. Prisma's ordinary database reads omit those values, so rotate |
| 105 | * the observed default connection once when no canonical URL is available. |
| 106 | */ |
| 107 | export const recoverDatabaseConnectionSecrets = Effect.fn(function* < |
| 108 | D extends ObservedDatabase, |
| 109 | >(initialDatabase: D, known: PrismaSecretConnection) { |
| 110 | if (initialDatabase.status === "failure") { |
| 111 | return yield* Effect.fail( |
| 112 | new Error( |
| 113 | `Prisma database '${initialDatabase.name}' (${initialDatabase.id}) is in terminal status 'failure'; credentials cannot be recovered.`, |
| 114 | ), |
| 115 | ); |
| 116 | } |
| 117 | let database: D | GetDatabaseResponse["data"] = initialDatabase; |
| 118 | const observedConnection = |
| 119 | database.connections.find( |
| 120 | (connection) => connection.id === database.defaultConnectionId, |
| 121 | ) ?? database.connections[0]; |
| 122 | const available = mergeConnectionSecrets( |
| 123 | extractConnectionSecrets(observedConnection), |
| 124 | known, |
| 125 | ); |
| 126 | if (hasCanonicalConnectionSecrets(available)) { |
| 127 | return { database, secrets: available }; |
| 128 | } |
| 129 | |
| 130 | if (database.status !== "ready" || database.defaultConnectionId === null) { |
| 131 | database = yield* waitForRotatableDatabase(database); |
| 132 | } |
| 133 | |
| 134 | const refreshedConnection = |
| 135 | database.connections.find( |
| 136 | (connection) => connection.id === database.defaultConnectionId, |
| 137 | ) ?? database.connections[0]; |
| 138 | const refreshed = mergeConnectionSecrets( |
| 139 | extractConnectionSecrets(refreshedConnection), |
| 140 | available, |
| 141 | ); |
| 142 | if (hasCanonicalConnectionSecrets(refreshed)) { |
| 143 | return { database, secrets: refreshed }; |
| 144 | } |
| 145 | |
| 146 | const connectionId = database.defaultConnectionId; |
| 147 | if (connectionId === null) { |
| 148 | return yield* Effect.fail( |
| 149 | new DatabaseCredentialsNotReady( |
| 150 | `Prisma database '${database.name}' (${database.id}) was reported ready without a defaultConnectionId after the credential recovery wait.`, |
| 151 | ), |
| 152 | ); |
| 153 | } |
| 154 | const rotated = yield* createConnectionRotate({ id: connectionId }).pipe( |
| 155 | // Rotation mints new credentials; a replay would revoke the ones we |
| 156 | // just persisted, so opt out of the retry policy. |
| 157 | Retry.none, |
| 158 | Effect.map((response) => response.data), |
| 159 | ); |
| 160 | if (rotated.id !== connectionId || rotated.database.id !== database.id) { |
| 161 | return yield* Effect.fail( |
| 162 | new Error( |
| 163 | `Prisma rotated connection '${rotated.id}' for database '${rotated.database.id}', but connection '${connectionId}' belonging to database '${database.id}' was requested. Refusing to persist mismatched credentials.`, |
| 164 | ), |
| 165 | ); |
| 166 | } |
| 167 | const recovered = mergeConnectionSecrets( |
| 168 | extractConnectionSecrets(rotated), |
| 169 | refreshed, |
| 170 | ); |
| 171 | if (!hasCanonicalConnectionSecrets(recovered)) { |
| 172 | return yield* Effect.fail( |
| 173 | new Error( |
| 174 | `Prisma rotated default connection '${connectionId}' for database '${database.id}' without returning a canonical connection URL. Retry after database provisioning completes.`, |
| 175 | ), |
| 176 | ); |
| 177 | } |
| 178 | return { database, secrets: recovered }; |
| 179 | }); |