1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../AdoptPolicy.ts";
5import { isResolved } from "../Diff.ts";
6import * as Provider from "../Provider.ts";
7import {
8 DEV_TIMESTAMP,
9 attrOrNullableString,
10 attrOrRedactedString,
11 attrOrString,
12 devId,
13 devProvider,
14} from "./Internal/DevStub.ts";
15import * as ProviderLayer from "../Local/ProviderLayer.ts";
16import { Resource } from "../Resource.ts";
17import {
18 type GetConnectionsResponse,
19 deleteConnection,
20 getConnections,
21 getConnection,
22 getDatabaseConnections,
23 createConnection,
24 createConnectionRotate,
25} from "@distilled.cloud/prisma/management";
26import { Retry } from "@distilled.cloud/prisma";
27import { extractConnectionSecrets } from "./Client.ts";
28import type { Database } from "./Database.ts";
29import {
30 deriveConnectionAttrs,
31 hasCanonicalConnectionSecrets,
32} from "./Internal/DatabaseSecrets.ts";
33import { physicalInstanceName } from "./Internal/EnvName.ts";
34import type { PostgresOrigin } from "./PostgresOrigin.ts";
35import type { Providers } from "./Providers.ts";
36import {
37 concreteIdsChanged,
38 isInputObject,
39 isPrismaDevId,
40 resolveDatabaseId,
41 unresolvedDatabaseIdOf,
42} from "./Refs.ts";
43import type { ObservedConnectionRecord } from "./Internal/Observed.ts";
44import type { PrismaSecretConnection } from "./Types.ts";
45import { PrismaPaginationError } from "./Internal/Pagination.ts";
46
47export interface ConnectionProps {
48 /**
49 * Database ID or `database.databaseId` output this connection belongs to.
50 */
51 database: string | Database;
52 /**
53 * Human-readable connection name prefix. Alchemy appends the resource
54 * instance identity because Prisma permits duplicate connection names and
55 * exposes no ownership tags.
56 *
57 * @default the resource's logical ID
58 */
59 name?: string;
60 /**
61 * Rotate credentials when this value changes from `false` to `true` while
62 * keeping the connection ID. Prisma revokes the previous credentials on a
63 * best-effort basis. Deploy `false` before changing back to `true` for a
64 * later rotation.
65 *
66 * @default false
67 */
68 rotate?: boolean;
69}
70
71export interface Connection extends Resource<
72 "Prisma.Connection",
73 ConnectionProps,
74 {
75 /**
76 * Prisma connection/API key ID.
77 */
78 connectionId: string;
79 /**
80 * Connection display name.
81 */
82 connectionName: string;
83 /**
84 * Database ID this connection belongs to.
85 */
86 databaseId: string;
87 /**
88 * Connection kind returned by Prisma.
89 */
90 kind: "postgres" | "accelerate";
91 /**
92 * ISO timestamp when the connection was created.
93 */
94 createdAt: string;
95 /**
96 * Direct Postgres connection string, redacted in state.
97 */
98 directConnectionString: Redacted.Redacted<string> | undefined;
99 /**
100 * Pooled Postgres connection string, redacted in state.
101 */
102 pooledConnectionString: Redacted.Redacted<string> | undefined;
103 /**
104 * Accelerate connection string, redacted in state.
105 */
106 accelerateConnectionString: Redacted.Redacted<string> | undefined;
107 /**
108 * Direct database host, when returned by Prisma.
109 */
110 host: string | null | undefined;
111 /**
112 * Direct database username, when returned by Prisma.
113 */
114 user: string | null | undefined;
115 /**
116 * Direct database password, redacted in state.
117 */
118 password: Redacted.Redacted<string> | undefined;
119 /**
120 * Conventional application database URL, redacted in state.
121 *
122 * Resolves to the pooled Postgres URL first, then direct Postgres, then
123 * Accelerate — the serverless-safe default for application traffic.
124 */
125 databaseUrl: Redacted.Redacted<string> | undefined;
126 /**
127 * Parsed direct connection components ready to feed into a Postgres
128 * origin — e.g. `Cloudflare.Hyperdrive`'s `origin` prop. Points at the
129 * direct (non-pooled) endpoint, which is the recommended target when
130 * fronting Prisma Postgres with another pooler like Hyperdrive.
131 */
132 origin: PostgresOrigin | undefined;
133 /**
134 * Parsed pooled connection components. Useful as a Hyperdrive `dev`
135 * origin when local workers bypass Hyperdrive and connect directly.
136 */
137 pooledOrigin: PostgresOrigin | undefined;
138 },
139 never,
140 Providers
141> {}
142
143/**
144 * A Prisma database connection/API key.
145 *
146 * Prisma returns connection credentials only when it creates or rotates a
147 * connection. Alchemy stores those outputs as `Redacted` values. Changing the
148 * database or name replaces the connection; changing `rotate` from `false` to
149 * `true` keeps the connection ID and requests fresh credentials.
150 *
151 * ### Creating a Connection
152 * **Example:** Application connection
153 * ```typescript
154 * const connection = yield* Prisma.Connection("api", {
155 * database: database.databaseId,
156 * });
157 * ```
158 *
159 * ### Binding to Platforms
160 * **Example:** Pass database URLs to Compute env
161 * ```typescript
162 * const connection = yield* Prisma.Connection("api", {
163 * database,
164 * });
165 *
166 * const app = yield* Prisma.Compute("api", {
167 * project,
168 * path: "./apps/api",
169 * env: {
170 * DATABASE_URL: connection.databaseUrl,
171 * DIRECT_URL: connection.directConnectionString,
172 * },
173 * });
174 * ```
175 *
176 * **Example:** Use a connection inside an Effect-native Compute app
177 * ```typescript
178 * export default Prisma.Compute(
179 * "api",
180 * { project, appName: "api", main: import.meta.filename },
181 * Effect.gen(function* () {
182 * const db = yield* Prisma.Connect(connection);
183 * const sql = yield* SQL.Postgres({ url: db.databaseUrl });
184 * return {
185 * fetch: Effect.gen(function* () {
186 * const users = yield* sql`SELECT * FROM users`;
187 * return yield* HttpServerResponse.json(users);
188 * }),
189 * };
190 * }).pipe(Effect.provide(Prisma.ConnectBinding)),
191 * );
192 * ```
193 *
194 * **Example:** Use a connection inside an Effect-native Lambda function
195 * ```typescript
196 * export default AWS.Lambda.Function(
197 * "api",
198 * { main: import.meta.filename, functionUrl: true },
199 * Effect.gen(function* () {
200 * const db = yield* Prisma.Connect(connection);
201 * const sql = yield* SQL.Postgres({ url: db.databaseUrl });
202 * return {
203 * fetch: Effect.gen(function* () {
204 * const users = yield* sql`SELECT * FROM users`;
205 * return yield* HttpServerResponse.json(users);
206 * }),
207 * };
208 * }).pipe(Effect.provide(Prisma.ConnectBinding)),
209 * );
210 * ```
211 *
212 * **Example:** Use a connection inside an Effect-native Cloudflare Worker
213 * ```typescript
214 * export default Cloudflare.Worker(
215 * "api",
216 * { main: import.meta.filename, compatibility: { flags: ["nodejs_compat"] } },
217 * Effect.gen(function* () {
218 * const db = yield* Prisma.Connect(connection);
219 * const sql = yield* SQL.Postgres({ url: db.databaseUrl });
220 * return {
221 * fetch: Effect.gen(function* () {
222 * const result = yield* sql`SELECT 1 AS ok`;
223 * return yield* HttpServerResponse.json(result);
224 * }),
225 * };
226 * }).pipe(Effect.provide(Prisma.ConnectBinding)),
227 * );
228 * ```
229 *
230 * ### Rotating Credentials
231 * **Example:** Request one rotation
232 * ```typescript
233 * const connection = yield* Prisma.Connection("api", {
234 * database,
235 * rotate: true,
236 * });
237 * ```
238 *
239 * ### Connecting over Hyperdrive
240 * **Example:** Front Prisma Postgres with Cloudflare Hyperdrive
241 * ```typescript
242 * const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("api-hd", {
243 * origin: connection.origin.as<Prisma.PostgresOrigin>(),
244 * });
245 *
246 * export default Cloudflare.Worker(
247 * "api",
248 * { main: import.meta.filename, compatibility: { flags: ["nodejs_compat"] } },
249 * Effect.gen(function* () {
250 * const hd = yield* Cloudflare.Hyperdrive.Connect(hyperdrive);
251 * const sql = yield* SQL.Postgres({ url: hd.connectionString });
252 * return {
253 * fetch: Effect.gen(function* () {
254 * const users = yield* sql`SELECT * FROM users`;
255 * return yield* HttpServerResponse.json(users);
256 * }),
257 * };
258 * }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),
259 * );
260 * ```
261 *
262 * @resource
263 * @product Postgres
264 */
265export const Connection = Resource<Connection>("Prisma.Connection");
266
267// Distilled emits the cursor-paginated list operations as plain ops, so
268// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
269const listDatabaseConnections = (databaseId: string) =>
270 Effect.gen(function* () {
271 const connections: GetConnectionsResponse["data"][number][] = [];
272 let cursor: string | undefined;
273 while (true) {
274 const page = yield* getDatabaseConnections(
275 cursor === undefined
276 ? { databaseId, limit: 100 }
277 : { databaseId, limit: 100, cursor },
278 );
279 connections.push(...page.data);
280 const nextCursor = page.pagination.nextCursor;
281 if (!page.pagination.hasMore) break;
282 if (nextCursor === null) {
283 return yield* Effect.fail(
284 new PrismaPaginationError({
285 message:
286 "Invalid Prisma Management API pagination response from getDatabaseConnections: hasMore was true without a non-empty nextCursor",
287 }),
288 );
289 }
290 cursor = nextCursor;
291 }
292 return connections;
293 });
294
295const listAllConnections = () =>
296 Effect.gen(function* () {
297 const connections: GetConnectionsResponse["data"][number][] = [];
298 let cursor: string | undefined;
299 while (true) {
300 const page = yield* getConnections(
301 cursor === undefined ? {} : { cursor },
302 );
303 connections.push(...page.data);
304 const nextCursor = page.pagination.nextCursor;
305 if (!page.pagination.hasMore) break;
306 if (nextCursor === null) {
307 return yield* Effect.fail(
308 new PrismaPaginationError({
309 message:
310 "Invalid Prisma Management API pagination response from getConnections: hasMore was true without a non-empty nextCursor",
311 }),
312 );
313 }
314 cursor = nextCursor;
315 }
316 return connections;
317 });
318
319const findConnection = (
320 databaseId: string,
321 predicate: (connection: ObservedConnectionRecord) => boolean,
322) =>
323 listDatabaseConnections(databaseId).pipe(
324 Effect.map((connections) => connections.filter(predicate)),
325 );
326
327class AmbiguousPrismaConnectionError extends Error {
328 readonly _tag = "AmbiguousPrismaConnectionError";
329
330 constructor(databaseId: string, name: string, count: number) {
331 super(
332 `Prisma database '${databaseId}' has ${count} connections named '${name}'; use a unique connection name before importing it into Alchemy`,
333 );
334 }
335}
336
337class InvalidPrismaConnectionNameError extends Error {
338 readonly _tag = "InvalidPrismaConnectionNameError";
339
340 constructor() {
341 super(
342 "Prisma connection name must contain at least one non-space character",
343 );
344 }
345}
346
347const validateConnectionName = (name: string) => {
348 const trimmed = name.trim();
349 return trimmed.length === 0
350 ? Effect.fail(new InvalidPrismaConnectionNameError())
351 : Effect.succeed(trimmed);
352};
353
354const uniqueConnection = (
355 databaseId: string,
356 description: string,
357 predicate: (connection: ObservedConnectionRecord) => boolean,
358) =>
359 findConnection(databaseId, predicate).pipe(
360 Effect.flatMap((connections) =>
361 connections.length <= 1
362 ? Effect.succeed(connections[0])
363 : Effect.fail(
364 new AmbiguousPrismaConnectionError(
365 databaseId,
366 description,
367 connections.length,
368 ),
369 ),
370 ),
371 );
372
373class GeneratedConnectionNotVisible extends Error {}
374
375const generatedConnectionRecoverySchedule = Schedule.max([
376 Schedule.exponential("250 millis"),
377 Schedule.recurs(6),
378]);
379
380const recoverGeneratedConnectionAfterConflict = (
381 databaseId: string,
382 expectedName: string,
383) =>
384 uniqueConnection(
385 databaseId,
386 expectedName,
387 (candidate) => candidate.name === expectedName,
388 ).pipe(
389 Effect.flatMap((connection) =>
390 connection
391 ? Effect.succeed(connection)
392 : Effect.fail(
393 new GeneratedConnectionNotVisible(
394 `Generated Prisma connection '${expectedName}' is not visible yet.`,
395 ),
396 ),
397 ),
398 Effect.retry({
399 while: (error) => error instanceof GeneratedConnectionNotVisible,
400 schedule: generatedConnectionRecoverySchedule,
401 }),
402 );
403
404const physicalConnectionPrefix = (name: string) =>
405 `${name.trim().slice(0, 52)}-`;
406
407const isGeneratedPhysicalConnectionName = (
408 physicalName: string,
409 logicalName: string,
410) => {
411 const prefix = physicalConnectionPrefix(logicalName);
412 return (
413 physicalName.startsWith(prefix) &&
414 /^[0-9a-f]{12}$/i.test(physicalName.slice(prefix.length))
415 );
416};
417
418const isAdoptablePhysicalConnectionName = (
419 physicalName: string,
420 logicalName: string,
421) =>
422 physicalName === logicalName.trim() ||
423 isGeneratedPhysicalConnectionName(physicalName, logicalName);
424
425const attrsFrom = (
426 connection: ObservedConnectionRecord,
427 secrets: PrismaSecretConnection,
428): Connection["Attributes"] => ({
429 connectionId: connection.id,
430 connectionName: connection.name,
431 databaseId: connection.database.id,
432 kind: connection.kind,
433 createdAt: connection.createdAt,
434 directConnectionString: secrets.directConnectionString,
435 pooledConnectionString: secrets.pooledConnectionString,
436 accelerateConnectionString: secrets.accelerateConnectionString,
437 host: secrets.host,
438 user: secrets.user,
439 password: secrets.password,
440 ...deriveConnectionAttrs(secrets),
441});
442
443const ProviderLive = () =>
444 Provider.effect(
445 Connection,
446 Effect.gen(function* () {
447 return {
448 stables: ["connectionId"],
449 list: () =>
450 listAllConnections().pipe(
451 Effect.map((connections) =>
452 connections.map((c) => attrsFrom(c, {})),
453 ),
454 ),
455 diff: Effect.fn(function* ({ id, olds, news, output }) {
456 if (!isInputObject(news)) return undefined;
457 if (isPrismaDevId(output?.connectionId)) {
458 return { action: "update" } as const;
459 }
460 const oldDatabaseId =
461 output?.databaseId ?? unresolvedDatabaseIdOf(olds.database);
462 const newDatabaseId = isResolved(news.database)
463 ? unresolvedDatabaseIdOf(news.database)
464 : undefined;
465 const resolvedName = isResolved(news.name)
466 ? yield* validateConnectionName(news.name ?? id)
467 : undefined;
468 const nameChanged =
469 resolvedName !== undefined &&
470 resolvedName !== (olds.name ?? id).trim();
471 if (concreteIdsChanged(oldDatabaseId, newDatabaseId) || nameChanged) {
472 return { action: "replace" } as const;
473 }
474 if (!isResolved(news.rotate)) return undefined;
475 if ((news.rotate ?? false) !== (olds.rotate ?? false)) {
476 return { action: "update" } as const;
477 }
478 return undefined;
479 }),
480 read: Effect.fn(function* ({ id, instanceId, output, olds }) {
481 const connectionId = isPrismaDevId(output?.connectionId)
482 ? undefined
483 : output?.connectionId;
484 if (connectionId && output) {
485 const connection = yield* getConnection({
486 id: connectionId,
487 }).pipe(
488 Effect.map((response) => response.data),
489 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
490 );
491 if (!connection) return undefined;
492 if (
493 connection.database.id !== output.databaseId ||
494 connection.name !== output.connectionName
495 ) {
496 return yield* Effect.fail(
497 new Error(
498 `Prisma connection '${connection.id}' no longer matches persisted database '${output.databaseId}' and name '${output.connectionName}'. Refusing to refresh a mismatched connection.`,
499 ),
500 );
501 }
502 const observed = extractConnectionSecrets(connection);
503 return attrsFrom(connection, {
504 directConnectionString: output?.directConnectionString,
505 pooledConnectionString: output?.pooledConnectionString,
506 accelerateConnectionString: output?.accelerateConnectionString,
507 host: observed.host,
508 user: output?.user,
509 password: output?.password,
510 });
511 }
512
513 const databaseId = unresolvedDatabaseIdOf(olds.database);
514 if (!databaseId) return undefined;
515 const name = yield* validateConnectionName(olds.name ?? id);
516 const expectedName = physicalInstanceName(name, instanceId);
517 const owned = yield* uniqueConnection(
518 databaseId,
519 expectedName,
520 (connection) => connection.name === expectedName,
521 );
522 if (owned) return attrsFrom(owned, {});
523
524 const prefix = physicalConnectionPrefix(name);
525 const generated = yield* uniqueConnection(
526 databaseId,
527 `${prefix}<instance-id>`,
528 (connection) =>
529 connection.name !== expectedName &&
530 isGeneratedPhysicalConnectionName(connection.name, name),
531 );
532 const connection =
533 generated ??
534 (yield* uniqueConnection(
535 databaseId,
536 name,
537 (connection) => connection.name === name,
538 ));
539 if (!connection) return undefined;
540 return Unowned(attrsFrom(connection, {}));
541 }),
542 reconcile: Effect.fn(function* ({
543 id,
544 instanceId,
545 news,
546 olds,
547 output,
548 }) {
549 const databaseId = yield* resolveDatabaseId(news.database);
550 const name = yield* validateConnectionName(news.name ?? id);
551 const connectionId = isPrismaDevId(output?.connectionId)
552 ? undefined
553 : output?.connectionId;
554 let connection: ObservedConnectionRecord | undefined = connectionId
555 ? yield* getConnection({ id: connectionId }).pipe(
556 Effect.map((response) => response.data),
557 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
558 )
559 : undefined;
560
561 const expectedName = physicalInstanceName(name, instanceId);
562 const physicalName =
563 connectionId && output ? output.connectionName : expectedName;
564 if (
565 connectionId &&
566 !isAdoptablePhysicalConnectionName(physicalName, name)
567 ) {
568 return yield* Effect.fail(
569 new Error(
570 `Persisted Prisma connection '${connectionId}' has physical name '${physicalName}', which does not match requested logical name '${name}'. Refusing to adopt or persist a mismatched connection.`,
571 ),
572 );
573 }
574 if (
575 connection &&
576 (connection.database.id !== databaseId ||
577 connection.name !== physicalName)
578 ) {
579 return yield* Effect.fail(
580 new Error(
581 `Prisma connection '${connection.id}' resolves to database '${connection.database.id}' with name '${connection.name}', not requested database '${databaseId}' and physical name '${physicalName}'. Refusing to rotate or persist mismatched identity; replace the connection instead.`,
582 ),
583 );
584 }
585
586 if (!connection && !connectionId) {
587 connection = yield* uniqueConnection(
588 databaseId,
589 expectedName,
590 (candidate) => candidate.name === expectedName,
591 );
592 }
593
594 let secrets: PrismaSecretConnection = connection
595 ? extractConnectionSecrets(connection)
596 : {};
597 if (!connection) {
598 const create = createConnection({
599 databaseId,
600 name: physicalName,
601 }).pipe(
602 // A replayed create would mint a second connection; the retry
603 // policy cannot see the request, so opt out explicitly.
604 Retry.none,
605 Effect.map((response) => response.data),
606 );
607 connection = yield* physicalName === expectedName
608 ? create.pipe(
609 Effect.catchTag("Conflict", () =>
610 recoverGeneratedConnectionAfterConflict(
611 databaseId,
612 expectedName,
613 ),
614 ),
615 )
616 : create;
617 secrets = extractConnectionSecrets(connection);
618 }
619 const missingCanonicalSecrets =
620 secrets.directConnectionString === undefined &&
621 secrets.pooledConnectionString === undefined &&
622 secrets.accelerateConnectionString === undefined &&
623 output?.directConnectionString === undefined &&
624 output?.pooledConnectionString === undefined &&
625 output?.accelerateConnectionString === undefined;
626 const recoveringOwnedGeneratedSecrets =
627 physicalName === expectedName && missingCanonicalSecrets;
628 if (
629 recoveringOwnedGeneratedSecrets ||
630 (news.rotate === true && olds?.rotate !== true)
631 ) {
632 const rotated = yield* createConnectionRotate({
633 id: connection.id,
634 }).pipe(
635 // Rotation mints new credentials; a replay would revoke the
636 // ones we just persisted, so opt out of the retry policy.
637 Retry.none,
638 Effect.map((response) => response.data),
639 );
640 if (
641 rotated.id !== connection.id ||
642 rotated.database.id !== databaseId ||
643 rotated.name !== physicalName
644 ) {
645 return yield* Effect.fail(
646 new Error(
647 `Prisma rotated connection '${rotated.id}' for database '${rotated.database.id}' with name '${rotated.name}', but connection '${connection.id}' for database '${databaseId}' with name '${physicalName}' was requested. Refusing to persist mismatched credentials.`,
648 ),
649 );
650 }
651 connection = rotated;
652 secrets = extractConnectionSecrets(rotated);
653 if (!hasCanonicalConnectionSecrets(secrets)) {
654 return yield* Effect.fail(
655 new Error(
656 `Prisma rotated connection '${connection.id}' for database '${databaseId}' without returning a canonical connection URL. Refusing to persist missing or stale credentials.`,
657 ),
658 );
659 }
660 }
661
662 return attrsFrom(connection, {
663 directConnectionString:
664 secrets.directConnectionString ?? output?.directConnectionString,
665 pooledConnectionString:
666 secrets.pooledConnectionString ?? output?.pooledConnectionString,
667 accelerateConnectionString:
668 secrets.accelerateConnectionString ??
669 output?.accelerateConnectionString,
670 host: secrets.host ?? output?.host,
671 user: secrets.user ?? output?.user,
672 password: secrets.password ?? output?.password,
673 });
674 }),
675 delete: Effect.fn(function* ({ output }) {
676 if (isPrismaDevId(output.connectionId)) return;
677 const connection = yield* getConnection({
678 id: output.connectionId,
679 }).pipe(
680 Effect.map((response) => response.data),
681 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
682 );
683 if (!connection) return;
684 if (
685 connection.database.id !== output.databaseId ||
686 connection.name !== output.connectionName
687 ) {
688 return yield* Effect.fail(
689 new Error(
690 `Prisma connection '${connection.id}' no longer matches persisted database '${output.databaseId}' and name '${output.connectionName}'. Refusing to delete a mismatched connection.`,
691 ),
692 );
693 }
694 yield* deleteConnection({ id: connection.id }).pipe(
695 Effect.catchTag("NotFound", () => Effect.void),
696 );
697 }),
698 };
699 }),
700 );
701
702const ProviderLocal = () =>
703 devProvider(Connection, ["connectionId"], ({ id, news }) => {
704 const secrets = {
705 directConnectionString: attrOrRedactedString(
706 news.database,
707 "directConnectionString",
708 ),
709 pooledConnectionString: attrOrRedactedString(
710 news.database,
711 "pooledConnectionString",
712 ),
713 accelerateConnectionString: attrOrRedactedString(
714 news.database,
715 "accelerateConnectionString",
716 ),
717 };
718 return {
719 connectionId: devId("connection", id),
720 connectionName: news.name ?? id,
721 databaseId: attrOrString(news.database, "databaseId"),
722 kind: "postgres",
723 createdAt: DEV_TIMESTAMP,
724 ...secrets,
725 host: attrOrNullableString(news.database, "host"),
726 user: attrOrNullableString(news.database, "user"),
727 password: attrOrRedactedString(news.database, "password"),
728 ...deriveConnectionAttrs(secrets),
729 };
730 });
731
732export const ConnectionProvider = () =>
733 ProviderLayer.dual(Connection, {
734 local: () => ProviderLocal(),
735 live: () => ProviderLive(),
736 });