| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as Binding from "../Binding.ts"; |
| 5 | import * as Output from "../Output.ts"; |
| 6 | import type { Resource, ResourceLike } from "../Resource.ts"; |
| 7 | import { RuntimeContext } from "../RuntimeContext.ts"; |
| 8 | import type { Connection } from "./Connection.ts"; |
| 9 | import { envName } from "./Internal/EnvName.ts"; |
| 10 | |
| 11 | /** |
| 12 | * The typed runtime client returned by {@link Connect}. |
| 13 | */ |
| 14 | export interface ConnectClient { |
| 15 | /** |
| 16 | * Conventional application database URL, redacted. |
| 17 | * |
| 18 | * Resolves to the pooled Postgres URL first, then direct Postgres, then |
| 19 | * Accelerate — the serverless-safe default for application traffic. Dies |
| 20 | * with a descriptive defect when the connection carries no URL at all. |
| 21 | * |
| 22 | * Feed it straight into `SQL.Postgres` / `Drizzle.Postgres`: |
| 23 | * |
| 24 | * ```typescript |
| 25 | * const db = yield* Prisma.Connect(connection); |
| 26 | * const sql = yield* SQL.Postgres({ url: db.databaseUrl }); |
| 27 | * ``` |
| 28 | */ |
| 29 | databaseUrl: Effect.Effect<Redacted.Redacted<string>, never, RuntimeContext>; |
| 30 | /** |
| 31 | * Prisma connection/API key ID. |
| 32 | */ |
| 33 | connectionId: Effect.Effect<string, never, RuntimeContext>; |
| 34 | /** |
| 35 | * Database ID this connection belongs to. |
| 36 | */ |
| 37 | databaseId: Effect.Effect<string, never, RuntimeContext>; |
| 38 | /** |
| 39 | * Direct Postgres connection string, when available. |
| 40 | */ |
| 41 | directConnectionString: Effect.Effect< |
| 42 | Redacted.Redacted<string> | undefined, |
| 43 | never, |
| 44 | RuntimeContext |
| 45 | >; |
| 46 | /** |
| 47 | * Pooled Prisma Postgres connection string, when available. |
| 48 | */ |
| 49 | pooledConnectionString: Effect.Effect< |
| 50 | Redacted.Redacted<string> | undefined, |
| 51 | never, |
| 52 | RuntimeContext |
| 53 | >; |
| 54 | /** |
| 55 | * Accelerate connection string, when available. |
| 56 | */ |
| 57 | accelerateConnectionString: Effect.Effect< |
| 58 | Redacted.Redacted<string> | undefined, |
| 59 | never, |
| 60 | RuntimeContext |
| 61 | >; |
| 62 | /** |
| 63 | * Direct database host, when available. |
| 64 | */ |
| 65 | host: Effect.Effect<string | null | undefined, never, RuntimeContext>; |
| 66 | /** |
| 67 | * Direct database user, when available. |
| 68 | */ |
| 69 | user: Effect.Effect<string | null | undefined, never, RuntimeContext>; |
| 70 | /** |
| 71 | * Direct database password, when available. |
| 72 | */ |
| 73 | password: Effect.Effect< |
| 74 | Redacted.Redacted<string> | undefined, |
| 75 | never, |
| 76 | RuntimeContext |
| 77 | >; |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Bind a {@link Connection} to a Prisma Compute app, AWS Lambda Function, |
| 82 | * Cloudflare Worker, or Cloudflare Container and obtain the typed runtime |
| 83 | * client. |
| 84 | * |
| 85 | * `Connect` is a single identifier that is simultaneously the binding's |
| 86 | * Context tag, its type, and the callable — |
| 87 | * `yield* Prisma.Connect(connection)`. |
| 88 | * |
| 89 | * Provide `Prisma.ConnectBinding` on the host implementation so Alchemy can |
| 90 | * register the deploy-time binding and resolve the client at runtime. |
| 91 | * |
| 92 | * ### Binding a Connection |
| 93 | * **Example:** Use a connection inside Prisma Compute |
| 94 | * ```typescript |
| 95 | * export default Prisma.Compute( |
| 96 | * "api", |
| 97 | * { project, main: import.meta.filename }, |
| 98 | * Effect.gen(function* () { |
| 99 | * const db = yield* Prisma.Connect(connection); |
| 100 | * const sql = yield* SQL.Postgres({ url: db.databaseUrl }); |
| 101 | * |
| 102 | * return { |
| 103 | * fetch: Effect.gen(function* () { |
| 104 | * const users = yield* sql`SELECT * FROM users`; |
| 105 | * return yield* HttpServerResponse.json(users); |
| 106 | * }), |
| 107 | * }; |
| 108 | * }).pipe(Effect.provide(Prisma.ConnectBinding)), |
| 109 | * ); |
| 110 | * ``` |
| 111 | * |
| 112 | * **Example:** Use a connection inside a Cloudflare Container |
| 113 | * ```typescript |
| 114 | * export default Api.make( |
| 115 | * { main: import.meta.url }, |
| 116 | * Effect.gen(function* () { |
| 117 | * const db = yield* Prisma.Connect(connection); |
| 118 | * const sql = yield* SQL.Postgres({ url: db.databaseUrl }); |
| 119 | * |
| 120 | * return Api.of({ |
| 121 | * fetch: Effect.gen(function* () { |
| 122 | * const users = yield* sql`SELECT * FROM users`; |
| 123 | * return yield* HttpServerResponse.json(users); |
| 124 | * }), |
| 125 | * }); |
| 126 | * }).pipe(Effect.provide(Prisma.ConnectBinding)), |
| 127 | * ); |
| 128 | * ``` |
| 129 | * |
| 130 | * A container is a real process with no workerd bindings, so the connection |
| 131 | * travels as plain environment variables — the same channel Prisma Compute |
| 132 | * and Lambda use. Start the container with |
| 133 | * `Cloudflare.Containers.layer(Api, { enableInternet: true })` so it can |
| 134 | * reach the database. (Hyperdrive, by contrast, is a workerd binding and is |
| 135 | * unavailable inside a container.) |
| 136 | * |
| 137 | * @binding |
| 138 | * @product Postgres |
| 139 | */ |
| 140 | export interface Connect extends Binding.Service< |
| 141 | Connect, |
| 142 | "Prisma.Connect", |
| 143 | (connection: Connection) => Effect.Effect<ConnectClient> |
| 144 | > {} |
| 145 | |
| 146 | export const Connect = Binding.Service<Connect>("Prisma.Connect"); |
| 147 | |
| 148 | export interface ConnectEnvKeys { |
| 149 | connectionId: string; |
| 150 | databaseId: string; |
| 151 | directConnectionString: string; |
| 152 | pooledConnectionString: string; |
| 153 | accelerateConnectionString: string; |
| 154 | host: string; |
| 155 | user: string; |
| 156 | password: string; |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Derive the env var names {@link Connect} uses to carry a Connection's |
| 161 | * outputs into the host runtime. |
| 162 | */ |
| 163 | export const connectEnvKeys = ( |
| 164 | connection: Pick<Connection, "FQN" | "LogicalId">, |
| 165 | ): ConnectEnvKeys => { |
| 166 | const name = |
| 167 | connection.FQN === connection.LogicalId |
| 168 | ? connection.LogicalId |
| 169 | : connection.FQN; |
| 170 | const prefix = `PRISMA_${envName(name)}`; |
| 171 | return { |
| 172 | connectionId: `${prefix}_CONNECTION_ID`, |
| 173 | databaseId: `${prefix}_DATABASE_ID`, |
| 174 | directConnectionString: `${prefix}_DIRECT_CONNECTION_STRING`, |
| 175 | pooledConnectionString: `${prefix}_POOLED_CONNECTION_STRING`, |
| 176 | accelerateConnectionString: `${prefix}_ACCELERATE_CONNECTION_STRING`, |
| 177 | host: `${prefix}_HOST`, |
| 178 | user: `${prefix}_USER`, |
| 179 | password: `${prefix}_PASSWORD`, |
| 180 | }; |
| 181 | }; |
| 182 | |
| 183 | type ConnectEnvValue = Output.Output< |
| 184 | string | Redacted.Redacted<string> | undefined |
| 185 | >; |
| 186 | |
| 187 | type ConnectEnvBindingHost = Resource< |
| 188 | string, |
| 189 | object | undefined, |
| 190 | object, |
| 191 | { env?: Record<string, ConnectEnvValue> } |
| 192 | >; |
| 193 | |
| 194 | type ConnectWorkerTextBinding = |
| 195 | | { |
| 196 | type: "plain_text"; |
| 197 | name: string; |
| 198 | text: string; |
| 199 | } |
| 200 | | { |
| 201 | type: "secret_text"; |
| 202 | name: string; |
| 203 | text: string; |
| 204 | }; |
| 205 | |
| 206 | type ConnectWorkerBindingHost = Resource< |
| 207 | "Cloudflare.Worker", |
| 208 | object | undefined, |
| 209 | object, |
| 210 | { bindings?: ConnectWorkerTextBinding[] } |
| 211 | >; |
| 212 | |
| 213 | /** |
| 214 | * Hosts whose runtime configuration travels as plain environment variables: |
| 215 | * Prisma Compute, an AWS Lambda Function, and a Cloudflare Container. A |
| 216 | * container is a real process with no workerd bindings, so `env` is the only |
| 217 | * channel it has — the same contract the other two expose. |
| 218 | */ |
| 219 | const supportsConnectEnvBinding = ( |
| 220 | host: ResourceLike | undefined, |
| 221 | ): host is ConnectEnvBindingHost => |
| 222 | host?.Type === "Prisma.Compute" || |
| 223 | host?.Type === "AWS.Lambda.Function" || |
| 224 | host?.Type === "Cloudflare.Container"; |
| 225 | |
| 226 | const supportsConnectWorkerBinding = ( |
| 227 | host: ResourceLike | undefined, |
| 228 | ): host is ConnectWorkerBindingHost => host?.Type === "Cloudflare.Worker"; |
| 229 | |
| 230 | // Compute env sync omits undefined and treats null as deletion. Connection |
| 231 | // bindings need both values to round-trip into the typed runtime client. |
| 232 | const ENCODED_CONNECTION_PREFIX = "__ALCHEMY_PRISMA_CONNECTION_VALUE__:"; |
| 233 | |
| 234 | type EncodedConnectionValue = |
| 235 | | { readonly kind: "undefined" } |
| 236 | | { readonly kind: "null" } |
| 237 | | { readonly kind: "value"; readonly value: string }; |
| 238 | |
| 239 | const encodeConnectionValue = (value: EncodedConnectionValue) => |
| 240 | `${ENCODED_CONNECTION_PREFIX}${JSON.stringify(value)}`; |
| 241 | |
| 242 | const escapePrefixedValue = <A extends string | Redacted.Redacted<string>>( |
| 243 | value: A, |
| 244 | ): A | string | Redacted.Redacted<string> => { |
| 245 | const raw = typeof value === "string" ? value : String(Redacted.value(value)); |
| 246 | if (!raw.startsWith(ENCODED_CONNECTION_PREFIX)) return value; |
| 247 | const encoded = encodeConnectionValue({ kind: "value", value: raw }); |
| 248 | return Redacted.isRedacted(value) ? Redacted.make(encoded) : encoded; |
| 249 | }; |
| 250 | |
| 251 | const encodeOptionalValue = <A extends string | Redacted.Redacted<string>>( |
| 252 | output: Output.Output<A | null | undefined>, |
| 253 | ): Output.Output<A | string | Redacted.Redacted<string>> => |
| 254 | output.pipe( |
| 255 | Output.map((value) => |
| 256 | value === undefined |
| 257 | ? encodeConnectionValue({ kind: "undefined" }) |
| 258 | : value === null |
| 259 | ? encodeConnectionValue({ kind: "null" }) |
| 260 | : escapePrefixedValue(value), |
| 261 | ), |
| 262 | ) as Output.Output<A | string | Redacted.Redacted<string>>; |
| 263 | |
| 264 | const encodedConnectEnv = (connection: Connection) => ({ |
| 265 | connectionId: connection.connectionId, |
| 266 | databaseId: connection.databaseId, |
| 267 | directConnectionString: encodeOptionalValue( |
| 268 | connection.directConnectionString, |
| 269 | ), |
| 270 | pooledConnectionString: encodeOptionalValue( |
| 271 | connection.pooledConnectionString, |
| 272 | ), |
| 273 | accelerateConnectionString: encodeOptionalValue( |
| 274 | connection.accelerateConnectionString, |
| 275 | ), |
| 276 | host: encodeOptionalValue(connection.host), |
| 277 | user: encodeOptionalValue(connection.user), |
| 278 | password: encodeOptionalValue(connection.password), |
| 279 | }); |
| 280 | |
| 281 | const connectEnv = (connection: Connection) => { |
| 282 | const keys = connectEnvKeys(connection); |
| 283 | const env = encodedConnectEnv(connection); |
| 284 | return { |
| 285 | [keys.connectionId]: env.connectionId, |
| 286 | [keys.databaseId]: env.databaseId, |
| 287 | [keys.directConnectionString]: env.directConnectionString, |
| 288 | [keys.pooledConnectionString]: env.pooledConnectionString, |
| 289 | [keys.accelerateConnectionString]: env.accelerateConnectionString, |
| 290 | [keys.host]: env.host, |
| 291 | [keys.user]: env.user, |
| 292 | [keys.password]: env.password, |
| 293 | }; |
| 294 | }; |
| 295 | |
| 296 | const workerBindingValue = ( |
| 297 | name: string, |
| 298 | value: ConnectEnvValue, |
| 299 | ): Output.Output<ConnectWorkerTextBinding> => |
| 300 | value.pipe( |
| 301 | Output.map((resolved) => { |
| 302 | if (Redacted.isRedacted(resolved)) { |
| 303 | return { |
| 304 | type: "secret_text", |
| 305 | name, |
| 306 | text: Redacted.value(resolved), |
| 307 | }; |
| 308 | } |
| 309 | return { |
| 310 | type: "plain_text", |
| 311 | name, |
| 312 | text: resolved ?? encodeConnectionValue({ kind: "undefined" }), |
| 313 | }; |
| 314 | }), |
| 315 | ); |
| 316 | |
| 317 | const connectWorkerBindings = ( |
| 318 | connection: Connection, |
| 319 | ): Output.Output<ConnectWorkerTextBinding>[] => { |
| 320 | const keys = connectEnvKeys(connection); |
| 321 | const env = encodedConnectEnv(connection); |
| 322 | return [ |
| 323 | workerBindingValue(keys.connectionId, env.connectionId), |
| 324 | workerBindingValue(keys.databaseId, env.databaseId), |
| 325 | workerBindingValue(keys.directConnectionString, env.directConnectionString), |
| 326 | workerBindingValue(keys.pooledConnectionString, env.pooledConnectionString), |
| 327 | workerBindingValue( |
| 328 | keys.accelerateConnectionString, |
| 329 | env.accelerateConnectionString, |
| 330 | ), |
| 331 | workerBindingValue(keys.host, env.host), |
| 332 | workerBindingValue(keys.user, env.user), |
| 333 | workerBindingValue(keys.password, env.password), |
| 334 | ]; |
| 335 | }; |
| 336 | |
| 337 | const redactedToString = ( |
| 338 | value: Redacted.Redacted<string> | string | undefined, |
| 339 | ): string | undefined => |
| 340 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 341 | |
| 342 | const runtimeOutput = <A>( |
| 343 | key: string, |
| 344 | output: Output.Output<A>, |
| 345 | ): Effect.Effect<A, never, RuntimeContext> => |
| 346 | output.bind(key).pipe(Effect.flatMap((effect) => effect)); |
| 347 | |
| 348 | const decodeConnectionValue = ( |
| 349 | value: Redacted.Redacted<string> | string, |
| 350 | ): string | null | undefined => { |
| 351 | const raw = redactedToString(value); |
| 352 | if (raw === undefined || !raw.startsWith(ENCODED_CONNECTION_PREFIX)) { |
| 353 | return raw; |
| 354 | } |
| 355 | try { |
| 356 | const parsed = JSON.parse(raw.slice(ENCODED_CONNECTION_PREFIX.length)); |
| 357 | if (typeof parsed !== "object" || parsed === null || !("kind" in parsed)) { |
| 358 | return raw; |
| 359 | } |
| 360 | if (parsed.kind === "undefined") return undefined; |
| 361 | if (parsed.kind === "null") return null; |
| 362 | if (parsed.kind === "value" && typeof parsed.value === "string") { |
| 363 | return parsed.value; |
| 364 | } |
| 365 | return raw; |
| 366 | } catch { |
| 367 | return raw; |
| 368 | } |
| 369 | }; |
| 370 | |
| 371 | const optionalString = ( |
| 372 | value: Redacted.Redacted<string> | string, |
| 373 | ): string | undefined => decodeConnectionValue(value) ?? undefined; |
| 374 | |
| 375 | const optionalRedacted = ( |
| 376 | value: Redacted.Redacted<string> | string, |
| 377 | ): Redacted.Redacted<string> | undefined => { |
| 378 | const decoded = optionalString(value); |
| 379 | return decoded === undefined ? undefined : Redacted.make(decoded); |
| 380 | }; |
| 381 | |
| 382 | const nullableString = ( |
| 383 | value: Redacted.Redacted<string> | string, |
| 384 | ): string | null | undefined => decodeConnectionValue(value); |
| 385 | |
| 386 | /** |
| 387 | * Implementation layer for {@link Connect}. Provide it on the host |
| 388 | * Function/Worker Effect: |
| 389 | * |
| 390 | * ```typescript |
| 391 | * Effect.gen(function* () { |
| 392 | * const db = yield* Prisma.Connect(connection); |
| 393 | * // ... |
| 394 | * }).pipe(Effect.provide(Prisma.ConnectBinding)) |
| 395 | * ``` |
| 396 | */ |
| 397 | export const ConnectBinding = Layer.effect( |
| 398 | Connect, |
| 399 | Effect.gen(function* () { |
| 400 | return Effect.fn(function* (connection: Connection) { |
| 401 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 402 | const host = yield* Binding.Host; |
| 403 | if (supportsConnectEnvBinding(host)) { |
| 404 | yield* host.bind`${connection}`({ |
| 405 | env: connectEnv(connection), |
| 406 | }); |
| 407 | } else if (supportsConnectWorkerBinding(host)) { |
| 408 | yield* host.bind`${connection}`({ |
| 409 | bindings: connectWorkerBindings(connection), |
| 410 | }); |
| 411 | } else { |
| 412 | return yield* Effect.die( |
| 413 | new Error( |
| 414 | `Prisma.Connect supports Prisma.Compute, AWS.Lambda.Function, Cloudflare.Worker, and Cloudflare.Container runtimes, got '${host?.Type ?? "no host"}'`, |
| 415 | ), |
| 416 | ); |
| 417 | } |
| 418 | } |
| 419 | const keys = connectEnvKeys(connection); |
| 420 | const env = encodedConnectEnv(connection); |
| 421 | const directConnectionString = runtimeOutput( |
| 422 | keys.directConnectionString, |
| 423 | env.directConnectionString, |
| 424 | ).pipe(Effect.map(optionalRedacted)); |
| 425 | const pooledConnectionString = runtimeOutput( |
| 426 | keys.pooledConnectionString, |
| 427 | env.pooledConnectionString, |
| 428 | ).pipe(Effect.map(optionalRedacted)); |
| 429 | const accelerateConnectionString = runtimeOutput( |
| 430 | keys.accelerateConnectionString, |
| 431 | env.accelerateConnectionString, |
| 432 | ).pipe(Effect.map(optionalRedacted)); |
| 433 | const databaseUrl = Effect.all([ |
| 434 | pooledConnectionString, |
| 435 | directConnectionString, |
| 436 | accelerateConnectionString, |
| 437 | ]).pipe( |
| 438 | Effect.flatMap(([pooled, direct, accelerate]) => { |
| 439 | const url = pooled ?? direct ?? accelerate; |
| 440 | return url === undefined |
| 441 | ? Effect.die( |
| 442 | new Error( |
| 443 | "Prisma connection carries no connection URL (no pooled, direct, or Accelerate endpoint was bound)", |
| 444 | ), |
| 445 | ) |
| 446 | : Effect.succeed(url); |
| 447 | }), |
| 448 | ); |
| 449 | return { |
| 450 | databaseUrl, |
| 451 | connectionId: runtimeOutput(keys.connectionId, env.connectionId), |
| 452 | databaseId: runtimeOutput(keys.databaseId, env.databaseId), |
| 453 | directConnectionString, |
| 454 | pooledConnectionString, |
| 455 | accelerateConnectionString, |
| 456 | host: runtimeOutput(keys.host, env.host).pipe( |
| 457 | Effect.map(nullableString), |
| 458 | ), |
| 459 | user: runtimeOutput(keys.user, env.user).pipe( |
| 460 | Effect.map(nullableString), |
| 461 | ), |
| 462 | password: runtimeOutput(keys.password, env.password).pipe( |
| 463 | Effect.map(optionalRedacted), |
| 464 | ), |
| 465 | } satisfies ConnectClient; |
| 466 | }); |
| 467 | }), |
| 468 | ); |