1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Redacted from "effect/Redacted";
4import * as Binding from "../Binding.ts";
5import * as Output from "../Output.ts";
6import type { Resource, ResourceLike } from "../Resource.ts";
7import { RuntimeContext } from "../RuntimeContext.ts";
8import type { Connection } from "./Connection.ts";
9import { envName } from "./Internal/EnvName.ts";
10
11/**
12 * The typed runtime client returned by {@link Connect}.
13 */
14export 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 */
140export interface Connect extends Binding.Service<
141 Connect,
142 "Prisma.Connect",
143 (connection: Connection) => Effect.Effect<ConnectClient>
144> {}
145
146export const Connect = Binding.Service<Connect>("Prisma.Connect");
147
148export 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 */
163export 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
183type ConnectEnvValue = Output.Output<
184 string | Redacted.Redacted<string> | undefined
185>;
186
187type ConnectEnvBindingHost = Resource<
188 string,
189 object | undefined,
190 object,
191 { env?: Record<string, ConnectEnvValue> }
192>;
193
194type 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
206type 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 */
219const 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
226const 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.
232const ENCODED_CONNECTION_PREFIX = "__ALCHEMY_PRISMA_CONNECTION_VALUE__:";
233
234type EncodedConnectionValue =
235 | { readonly kind: "undefined" }
236 | { readonly kind: "null" }
237 | { readonly kind: "value"; readonly value: string };
238
239const encodeConnectionValue = (value: EncodedConnectionValue) =>
240 `${ENCODED_CONNECTION_PREFIX}${JSON.stringify(value)}`;
241
242const 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
251const 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
264const 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
281const 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
296const 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
317const 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
337const redactedToString = (
338 value: Redacted.Redacted<string> | string | undefined,
339): string | undefined =>
340 Redacted.isRedacted(value) ? Redacted.value(value) : value;
341
342const 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
348const 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
371const optionalString = (
372 value: Redacted.Redacted<string> | string,
373): string | undefined => decodeConnectionValue(value) ?? undefined;
374
375const 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
382const 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 */
397export 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);