1import * as Effect from "effect/Effect";
2import * as Schedule from "effect/Schedule";
3import { deepEqual, isResolved } from "../Diff.ts";
4import * as Redacted from "effect/Redacted";
5import { Unowned } from "../AdoptPolicy.ts";
6import { createPhysicalName } from "../PhysicalName.ts";
7import * as Provider from "../Provider.ts";
8import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
9import type { Scope } from "effect/Scope";
10import type * as Path from "effect/Path";
11import {
12 closePrismaDevDatabase,
13 ensurePrismaDevDatabase,
14} from "./PrismaDevDatabase.ts";
15import { DEV_TIMESTAMP, attrOrString, devId } from "./Internal/DevStub.ts";
16import * as ProviderLayer from "../Local/ProviderLayer.ts";
17import { Resource } from "../Resource.ts";
18import {
19 type GetDatabasesResponse,
20 type GetProjectBranchesResponse,
21 type GetProjectDatabasesResponse,
22 deleteDatabase,
23 getDatabases,
24 getDatabase,
25 getProjectBranches,
26 getProjectDatabases,
27 updateDatabase,
28 createDatabase,
29} from "@distilled.cloud/prisma/management";
30import { Retry } from "@distilled.cloud/prisma";
31import { extractConnectionSecrets } from "./Client.ts";
32import type { Project } from "./Project.ts";
33import {
34 hasCanonicalConnectionSecrets,
35 mergeConnectionSecrets,
36 recoverDatabaseConnectionSecrets,
37} from "./Internal/DatabaseSecrets.ts";
38import type { Providers } from "./Providers.ts";
39import {
40 concreteIdsChanged,
41 isInputObject,
42 isPrismaDevId,
43 resolveProjectId,
44 unresolvedProjectIdOf,
45} from "./Refs.ts";
46import {
47 type ObservedProjectDatabase,
48 type ObservedSource,
49 narrowDatabaseSource,
50} from "./Internal/Observed.ts";
51import type {
52 DatabaseSourceInput,
53 PrismaDatabaseRegionId,
54 PrismaRegionId,
55 PrismaSecretConnection,
56} from "./Types.ts";
57import { PrismaPaginationError } from "./Internal/Pagination.ts";
58
59export interface DatabaseDev {
60 /**
61 * Local provider used by `alchemy dev`.
62 *
63 * @default "@prisma/dev"
64 */
65 provider?: "@prisma/dev";
66 /**
67 * Stable local server name.
68 */
69 name?: string;
70 /**
71 * Local storage mode for the database server.
72 *
73 * @default "stateful"
74 */
75 persistenceMode?: "stateless" | "stateful";
76 /**
77 * HTTP control port for the local server.
78 */
79 port?: number;
80 /**
81 * Direct Postgres port for the local database.
82 */
83 databasePort?: number;
84 /**
85 * Direct Postgres port for the local shadow database.
86 */
87 shadowDatabasePort?: number;
88 /**
89 * Enable local provider debug logging.
90 *
91 * @default false
92 */
93 debug?: boolean;
94 /**
95 * Connection timeout in milliseconds for pending database clients.
96 */
97 databaseConnectTimeoutMillis?: number;
98 /**
99 * Idle timeout in milliseconds for active database clients.
100 */
101 databaseIdleTimeoutMillis?: number;
102 /**
103 * Connection timeout in milliseconds for pending shadow database clients.
104 */
105 shadowDatabaseConnectTimeoutMillis?: number;
106 /**
107 * Idle timeout in milliseconds for active shadow database clients.
108 */
109 shadowDatabaseIdleTimeoutMillis?: number;
110 /**
111 * Optional shell command to run after the local database is ready.
112 */
113 migrate?: string;
114 /**
115 * Working directory for the migration command.
116 */
117 migrateCwd?: string;
118 /**
119 * Maximum time to wait for the migration command before terminating it.
120 * Must be a positive finite number.
121 *
122 * @default 900
123 */
124 migrateTimeoutSeconds?: number;
125}
126
127export interface DatabaseProps {
128 /**
129 * Project ID or `project.projectId` output that owns this database.
130 */
131 project: string | Project;
132 /**
133 * Database display name. If omitted, Alchemy generates a stable physical
134 * name so interrupted creates can be recovered without duplicating a
135 * database. Explicit names cannot be combined with branch attachment during
136 * initial creation because the Management API creates the database before it
137 * attaches the branch and exposes no idempotency key.
138 */
139 name?: string;
140 /**
141 * Region for the database.
142 *
143 * @default "us-east-1"
144 */
145 region?: PrismaDatabaseRegionId;
146 /**
147 * Standalone Prisma.Database resources cannot be the project's default
148 * database because the Management API cannot demote or promote an existing
149 * database, making the resource impossible to destroy safely. Use
150 * Prisma.Project to manage the project-owned default database.
151 *
152 * @default false
153 */
154 isDefault?: false;
155 /**
156 * Optional source database/backup descriptor for clone or restore creation.
157 */
158 source?: DatabaseSourceInput;
159 /**
160 * Branch ID to attach the database to. Mutually exclusive with
161 * branchGitName. Every Prisma database belongs to a Branch: omit both
162 * fields to let the Management API attach it to the project's default
163 * Branch, which Alchemy then leaves unmanaged.
164 */
165 branchId?: string;
166 /**
167 * Branch git name to attach the database to (the Branch is created when it
168 * does not exist). Mutually exclusive with branchId. Omit both fields to
169 * attach to the project's default Branch.
170 */
171 branchGitName?: string;
172 /**
173 * Local database settings for `alchemy dev`. Set to `false` to keep only
174 * placeholder IDs.
175 */
176 dev?: false | DatabaseDev;
177 /**
178 * Rotate the adopted database's default connection to recover its one-time
179 * credentials. Prisma revokes the previous key on a best-effort basis and
180 * rotation may interrupt existing consumers, so adoption leaves credentials
181 * unset unless explicitly opted in.
182 *
183 * @default false
184 */
185 rotateCredentialsOnAdopt?: boolean;
186}
187
188export interface Database extends Resource<
189 "Prisma.Database",
190 DatabaseProps,
191 {
192 /**
193 * Prisma database ID.
194 */
195 databaseId: string;
196 /**
197 * Prisma database display name.
198 */
199 databaseName: string;
200 /**
201 * Project ID that owns the database.
202 */
203 projectId: string;
204 /**
205 * Current Prisma database status.
206 */
207 status: string;
208 /**
209 * Prisma Postgres region ID, when available.
210 */
211 region: string | null;
212 /**
213 * Whether this is the project's default database.
214 */
215 isDefault: boolean;
216 /**
217 * Branch ID attached to the database, or null when unassigned.
218 */
219 branchId: string | null;
220 /**
221 * Default connection ID for the database.
222 */
223 defaultConnectionId: string | null;
224 /**
225 * ISO timestamp when the database was created.
226 */
227 createdAt: string;
228 /**
229 * Direct Postgres connection string, redacted in state.
230 */
231 directConnectionString: Redacted.Redacted<string> | undefined;
232 /**
233 * Pooled Postgres connection string, redacted in state.
234 */
235 pooledConnectionString: Redacted.Redacted<string> | undefined;
236 /**
237 * Accelerate connection string, redacted in state.
238 */
239 accelerateConnectionString: Redacted.Redacted<string> | undefined;
240 /**
241 * Direct database host, when returned by Prisma.
242 */
243 host: string | null | undefined;
244 /**
245 * Direct database username, when returned by Prisma.
246 */
247 user: string | null | undefined;
248 /**
249 * Direct database password, redacted in state.
250 */
251 password: Redacted.Redacted<string> | undefined;
252 },
253 never,
254 Providers
255> {}
256
257/**
258 * A Prisma Postgres database inside a Prisma project.
259 *
260 * Standalone `Prisma.Database` resources cannot be the project's default
261 * database. Use `Prisma.Project` when the project should own a default
262 * database. Project, region, and source changes require replacement; display
263 * name and branch attachment can converge in place. Destroying this resource
264 * deletes its database and data.
265 *
266 * ### Creating a Database
267 * **Example:** Database in a project
268 * ```typescript
269 * const project = yield* Prisma.Project("app", { createDatabase: false });
270 * const database = yield* Prisma.Database("db", {
271 * project,
272 * region: "us-east-1",
273 * });
274 * ```
275 *
276 * **Example:** Database attached to a preview branch
277 * ```typescript
278 * const database = yield* Prisma.Database("preview-db", {
279 * project,
280 * branchId: preview.branchId,
281 * });
282 * ```
283 *
284 * @resource
285 * @product Postgres
286 */
287export const Database = Resource<Database>("Prisma.Database");
288
289const createName = (id: string, name: string | undefined) =>
290 name === undefined ? createPhysicalName({ id }) : Effect.succeed(name);
291
292// Distilled emits the cursor-paginated list operations as plain ops, so
293// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
294const listProjectDatabases = (projectId: string) =>
295 Effect.gen(function* () {
296 const databases: GetProjectDatabasesResponse["data"][number][] = [];
297 let cursor: string | undefined;
298 while (true) {
299 const page = yield* getProjectDatabases(
300 cursor === undefined
301 ? { projectId, limit: 100 }
302 : { projectId, limit: 100, cursor },
303 );
304 databases.push(...page.data);
305 const nextCursor = page.pagination.nextCursor;
306 if (!page.pagination.hasMore) break;
307 if (nextCursor === null) {
308 return yield* Effect.fail(
309 new PrismaPaginationError({
310 message:
311 "Invalid Prisma Management API pagination response from getProjectDatabases: hasMore was true without a non-empty nextCursor",
312 }),
313 );
314 }
315 cursor = nextCursor;
316 }
317 return databases;
318 });
319
320const listAllDatabases = () =>
321 Effect.gen(function* () {
322 const databases: GetDatabasesResponse["data"][number][] = [];
323 let cursor: string | undefined;
324 while (true) {
325 const page = yield* getDatabases(cursor === undefined ? {} : { cursor });
326 databases.push(...page.data);
327 const nextCursor = page.pagination.nextCursor;
328 if (!page.pagination.hasMore) break;
329 if (nextCursor === null) {
330 return yield* Effect.fail(
331 new PrismaPaginationError({
332 message:
333 "Invalid Prisma Management API pagination response from getDatabases: hasMore was true without a non-empty nextCursor",
334 }),
335 );
336 }
337 cursor = nextCursor;
338 }
339 return databases;
340 });
341
342const findDatabaseByName = (projectId: string, name: string) =>
343 listProjectDatabases(projectId).pipe(
344 Effect.flatMap((databases) => {
345 const matches = databases.filter((database) => database.name === name);
346 return matches.length > 1
347 ? Effect.fail(
348 new Error(
349 `Prisma project '${projectId}' has multiple databases named '${name}'; refusing to select one arbitrarily.`,
350 ),
351 )
352 : Effect.succeed(matches[0]);
353 }),
354 );
355
356class GeneratedDatabaseNotVisible extends Error {}
357
358const generatedDatabaseRecoverySchedule = Schedule.max([
359 Schedule.exponential("250 millis"),
360 Schedule.recurs(6),
361]);
362
363const recoverGeneratedDatabaseAfterConflict = (
364 projectId: string,
365 name: string,
366) =>
367 findDatabaseByName(projectId, name).pipe(
368 Effect.flatMap((database) =>
369 database
370 ? Effect.succeed(database)
371 : Effect.fail(
372 new GeneratedDatabaseNotVisible(
373 `Generated Prisma database '${name}' already exists but is not visible yet.`,
374 ),
375 ),
376 ),
377 Effect.retry({
378 while: (error) => error instanceof GeneratedDatabaseNotVisible,
379 schedule: generatedDatabaseRecoverySchedule,
380 }),
381 );
382
383const findDefaultDatabase = (projectId: string) =>
384 listProjectDatabases(projectId).pipe(
385 Effect.flatMap((databases) => {
386 const matches = databases.filter((database) => database.isDefault);
387 return matches.length > 1
388 ? Effect.fail(
389 new Error(
390 `Prisma project '${projectId}' has multiple default databases; refusing to select one arbitrarily.`,
391 ),
392 )
393 : Effect.succeed(matches[0]);
394 }),
395 );
396
397const resolveDatabaseRegion = Effect.fn(function* (
398 projectId: string,
399 region: PrismaDatabaseRegionId | undefined,
400) {
401 if (region !== "inherit") {
402 return (region ?? "us-east-1") as PrismaRegionId;
403 }
404 const database = yield* findDefaultDatabase(projectId);
405 const inherited = database?.region?.id;
406 if (inherited === undefined) {
407 return yield* Effect.fail(
408 new Error(
409 `Cannot resolve Prisma database region 'inherit' because project '${projectId}' has no default database region. Create or promote a default database first, or specify an explicit region.`,
410 ),
411 );
412 }
413 return inherited as PrismaRegionId;
414});
415
416const stripDatabaseIdPrefix = (databaseId: string) =>
417 databaseId.startsWith("db_") ? databaseId.slice(3) : databaseId;
418
419const normalizeDatabaseSource = (
420 source: DatabaseSourceInput | { readonly type: "unknown" } | undefined,
421) => {
422 if (source === undefined || source.type === "empty") {
423 return { type: "empty" as const };
424 }
425 if (source.type === "unknown") return { type: "unknown" as const };
426 return source.type === "database"
427 ? {
428 type: "database" as const,
429 databaseId: stripDatabaseIdPrefix(source.databaseId),
430 }
431 : {
432 type: "backup" as const,
433 databaseId: stripDatabaseIdPrefix(source.databaseId),
434 backupId: source.backupId,
435 };
436};
437
438const sourceMatches = (
439 observed: ObservedSource | null,
440 desired: DatabaseSourceInput | undefined,
441) =>
442 deepEqual(
443 normalizeDatabaseSource(narrowDatabaseSource(observed)),
444 normalizeDatabaseSource(desired),
445 );
446
447const desiredSourcesMatch = (
448 left: DatabaseSourceInput | undefined,
449 right: DatabaseSourceInput | undefined,
450) => deepEqual(normalizeDatabaseSource(left), normalizeDatabaseSource(right));
451
452const branchIdForGitName = (projectId: string, gitName: string) =>
453 getProjectBranches({ projectId, gitName, limit: 2 }).pipe(
454 Effect.map((response: GetProjectBranchesResponse) => response.data),
455 Effect.flatMap((branches) =>
456 branches.length > 1
457 ? Effect.fail(
458 new Error(
459 `Prisma project '${projectId}' has multiple branches named '${gitName}'; refusing to select one arbitrarily.`,
460 ),
461 )
462 : Effect.succeed(branches[0]?.id),
463 ),
464 );
465
466const attrsFrom = (
467 database: ObservedProjectDatabase,
468 secrets: PrismaSecretConnection,
469): Database["Attributes"] => ({
470 databaseId: database.id,
471 databaseName: database.name,
472 projectId: database.project.id,
473 status: database.status,
474 region: database.region?.id ?? null,
475 isDefault: database.isDefault,
476 branchId: database.branchId,
477 defaultConnectionId: database.defaultConnectionId,
478 createdAt: database.createdAt,
479 directConnectionString: secrets.directConnectionString,
480 pooledConnectionString: secrets.pooledConnectionString,
481 accelerateConnectionString: secrets.accelerateConnectionString,
482 host: secrets.host,
483 user: secrets.user,
484 password: secrets.password,
485});
486
487const branchNeedsSync = Effect.fn(function* (
488 projectId: string,
489 database: ObservedProjectDatabase,
490 props: DatabaseProps,
491) {
492 if (props.branchId !== undefined && !isPrismaDevId(props.branchId)) {
493 return database.branchId !== props.branchId;
494 }
495 if (props.branchGitName === undefined) {
496 // No attachment requested: the Management API attaches every database to
497 // a Branch (the project default when omitted) and rejects detaching, so
498 // the observed attachment is left alone.
499 return false;
500 }
501 const branchId = yield* branchIdForGitName(projectId, props.branchGitName);
502 return branchId === undefined || branchId !== database.branchId;
503});
504
505const branchAttachment = (props: DatabaseProps) =>
506 props.branchId !== undefined && !isPrismaDevId(props.branchId)
507 ? {
508 branchId: props.branchId,
509 branchGitName: undefined,
510 }
511 : props.branchGitName !== undefined
512 ? {
513 branchId: undefined,
514 branchGitName: props.branchGitName,
515 }
516 : {
517 branchId: undefined,
518 branchGitName: undefined,
519 };
520
521const validateDatabaseProps = (props: DatabaseProps) =>
522 Effect.gen(function* () {
523 if ((props as { isDefault?: boolean }).isDefault === true) {
524 return yield* Effect.fail(
525 new Error(
526 "Prisma.Database cannot manage a default database because the Management API has no safe demotion or promote-existing operation, so the resource could never be destroyed. Use Prisma.Project for the project-owned default database.",
527 ),
528 );
529 }
530 if (props.branchId !== undefined && props.branchGitName !== undefined) {
531 return yield* Effect.fail(
532 new Error("branchId and branchGitName are mutually exclusive."),
533 );
534 }
535 if (
536 (props.branchId as unknown) === null ||
537 (props.branchGitName as unknown) === null
538 ) {
539 return yield* Effect.fail(
540 new Error(
541 "Every Prisma database belongs to a Branch; the Management API rejects detaching (null). Omit both branchId and branchGitName to attach to the project's default branch, or provide one of them.",
542 ),
543 );
544 }
545 });
546
547const ProviderLive = () =>
548 Provider.effect(
549 Database,
550 Effect.gen(function* () {
551 return {
552 stables: ["databaseId"],
553 list: () =>
554 listAllDatabases().pipe(
555 Effect.map((databases) =>
556 // Default databases are project-owned and the API rejects
557 // direct deletion. Project.list/delete owns their teardown;
558 // exposing them here would make unsafe nuke retry forever.
559 databases
560 .filter((database) => !database.isDefault)
561 .map((database) => attrsFrom(database, {})),
562 ),
563 ),
564 diff: Effect.fn(function* ({ id, olds, news, output }) {
565 if (!isInputObject(news)) return undefined;
566 if (
567 isResolved(news.rotateCredentialsOnAdopt) &&
568 news.rotateCredentialsOnAdopt === true &&
569 olds.rotateCredentialsOnAdopt !== true
570 ) {
571 return { action: "update" } as const;
572 }
573 if ((news as { isDefault?: unknown }).isDefault === true) {
574 return yield* Effect.fail(
575 new Error(
576 "Prisma.Database cannot manage a default database because the Management API has no safe demotion or promote-existing operation. Use Prisma.Project for the project-owned default database.",
577 ),
578 );
579 }
580 if (isPrismaDevId(output?.databaseId)) {
581 return { action: "update" } as const;
582 }
583 const oldProjectId =
584 output?.projectId ?? unresolvedProjectIdOf(olds.project);
585 const newProjectId = isResolved(news.project)
586 ? unresolvedProjectIdOf(news.project)
587 : undefined;
588 const desiredRegionInput = isResolved(news.region)
589 ? (news.region ?? "us-east-1")
590 : undefined;
591 const regionProjectId = newProjectId ?? oldProjectId;
592 const desiredRegion =
593 desiredRegionInput === "inherit"
594 ? regionProjectId
595 ? yield* resolveDatabaseRegion(
596 regionProjectId,
597 desiredRegionInput,
598 )
599 : undefined
600 : desiredRegionInput;
601 const observedRegion = output
602 ? output.region
603 : (olds.region ?? "us-east-1");
604 const desiredIsDefault = isResolved(news.isDefault)
605 ? (news.isDefault ?? false)
606 : undefined;
607 const observedIsDefault =
608 output?.isDefault ?? olds.isDefault ?? false;
609
610 // A default database cannot be deleted from its old project after a
611 // cross-project replacement. Block before creating anything until
612 // another database has been promoted in the original project.
613 if (
614 observedIsDefault &&
615 concreteIdsChanged(oldProjectId, newProjectId)
616 ) {
617 return { action: "update" } as const;
618 }
619
620 // Prisma has no API operation that directly demotes the current
621 // default database. Do not schedule a doomed create-first
622 // replacement: reconcile will fail before mutation until another
623 // database has been promoted and this one is observed as nondefault.
624 if (desiredIsDefault === false && observedIsDefault) {
625 return { action: "update" } as const;
626 }
627 if (
628 concreteIdsChanged(oldProjectId, newProjectId) ||
629 (desiredRegion !== undefined && desiredRegion !== observedRegion) ||
630 (desiredIsDefault !== undefined &&
631 desiredIsDefault !== observedIsDefault) ||
632 (isResolved(news.source) &&
633 !desiredSourcesMatch(news.source, olds.source))
634 ) {
635 return { action: "replace" } as const;
636 }
637 if (!isResolved(news.name)) return undefined;
638 const desiredName = yield* createName(id, news.name);
639 const observedName =
640 output?.databaseName ?? (yield* createName(id, olds.name));
641 // Omitting both branch fields leaves the observed attachment
642 // unmanaged (every database belongs to a Branch; detaching is not
643 // an API operation), so only an explicit target can mismatch.
644 let branchMismatch = false;
645 if (isResolved(news.branchId) && news.branchId !== undefined) {
646 branchMismatch =
647 !isPrismaDevId(news.branchId) &&
648 (output?.branchId ?? olds.branchId ?? null) !== news.branchId;
649 } else if (
650 isResolved(news.branchGitName) &&
651 news.branchGitName !== undefined
652 ) {
653 if (output && newProjectId !== undefined) {
654 const desiredBranchId = yield* branchIdForGitName(
655 newProjectId,
656 news.branchGitName,
657 );
658 branchMismatch =
659 desiredBranchId === undefined ||
660 desiredBranchId !== output.branchId;
661 } else {
662 branchMismatch = news.branchGitName !== olds.branchGitName;
663 }
664 }
665 if (desiredName !== observedName || branchMismatch) {
666 return { action: "update" } as const;
667 }
668 return undefined;
669 }),
670 read: Effect.fn(function* ({ id, output, olds }) {
671 const databaseId = isPrismaDevId(output?.databaseId)
672 ? undefined
673 : output?.databaseId;
674 let generatedIdentityMatch = false;
675 let database = databaseId
676 ? yield* getDatabase({ databaseId }).pipe(
677 Effect.map((response) => response.data),
678 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
679 )
680 : undefined;
681 if (!database && databaseId === undefined) {
682 const projectId = unresolvedProjectIdOf(olds.project);
683 if (projectId) {
684 const name = yield* createName(id, olds.name);
685 database = yield* findDatabaseByName(projectId, name);
686 generatedIdentityMatch =
687 database !== undefined && olds.name === undefined;
688 if (
689 !database &&
690 olds.name === undefined &&
691 (olds.isDefault ?? false)
692 ) {
693 database = yield* findDefaultDatabase(projectId);
694 }
695 }
696 }
697 if (!database) return undefined;
698 if (
699 databaseId === undefined &&
700 !sourceMatches(database.source, olds.source)
701 ) {
702 return yield* Effect.fail(
703 new Error(
704 `Prisma database '${database.name}' has immutable source ${JSON.stringify(database.source)} but ${JSON.stringify(olds.source ?? { type: "empty" })} was requested; refusing to adopt a database that cannot converge.`,
705 ),
706 );
707 }
708 const cachedSecrets =
709 output?.databaseId === database.id ? output : undefined;
710 const attrs = attrsFrom(database, {
711 directConnectionString: cachedSecrets?.directConnectionString,
712 pooledConnectionString: cachedSecrets?.pooledConnectionString,
713 accelerateConnectionString:
714 cachedSecrets?.accelerateConnectionString,
715 host: cachedSecrets?.host,
716 user: cachedSecrets?.user,
717 password: cachedSecrets?.password,
718 });
719 return databaseId === undefined && !generatedIdentityMatch
720 ? Unowned(attrs)
721 : attrs;
722 }),
723 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
724 yield* validateDatabaseProps(news);
725 const projectId = yield* resolveProjectId(news.project);
726 const region = yield* resolveDatabaseRegion(projectId, news.region);
727 const name = yield* createName(id, news.name);
728 const databaseId = isPrismaDevId(output?.databaseId)
729 ? undefined
730 : output?.databaseId;
731 let database: ObservedProjectDatabase | undefined = databaseId
732 ? yield* getDatabase({ databaseId }).pipe(
733 Effect.map((response) => response.data),
734 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
735 )
736 : undefined;
737 if (!database && news.name === undefined) {
738 database = yield* findDatabaseByName(projectId, name);
739 }
740
741 let secrets: PrismaSecretConnection = {};
742 let recoverCreateSecrets = false;
743 const attach = branchAttachment(news);
744 if (!database) {
745 if (
746 news.name !== undefined &&
747 (news.branchId !== undefined || news.branchGitName !== undefined)
748 ) {
749 return yield* Effect.fail(
750 new Error(
751 `Cannot safely create explicitly named Prisma database '${name}' with a branch attachment. The Management API creates the database before attaching the branch and exposes no idempotency key, so a failed response cannot be distinguished from a foreign database. Omit name to use Alchemy's recoverable physical identity, or wait for an atomic Management API operation.`,
752 ),
753 );
754 }
755 const result = yield* createDatabase({
756 projectId,
757 name,
758 region,
759 isDefault: news.isDefault ?? false,
760 ...(news.source === undefined ? {} : { source: news.source }),
761 branchId: attach.branchId,
762 branchGitName: attach.branchGitName,
763 }).pipe(
764 // A replayed create would make a second database; the retry
765 // policy cannot see the request, so opt out explicitly.
766 Retry.none,
767 Effect.map((response) => ({
768 database: response.data,
769 secrets: extractConnectionSecrets(response.data.connections[0]),
770 recoverSecrets: true,
771 })),
772 Effect.catchTag("Conflict", () =>
773 news.name === undefined
774 ? recoverGeneratedDatabaseAfterConflict(projectId, name).pipe(
775 Effect.map((database) => ({
776 database,
777 secrets: {},
778 // The generated physical name is owned by this
779 // resource instance. A conflict after the POST can
780 // be a lost successful response, so recover the
781 // write-only default credentials below.
782 recoverSecrets: true,
783 })),
784 )
785 : Effect.fail(
786 new Error(
787 `A Prisma database named '${name}' appeared after the adoption check. Refusing to take it over; rerun with adoption enabled if it is the intended database.`,
788 ),
789 ),
790 ),
791 );
792 database = result.database;
793 secrets = result.secrets;
794 recoverCreateSecrets = result.recoverSecrets;
795 }
796
797 if (database.project.id !== projectId) {
798 return yield* Effect.fail(
799 new Error(
800 database.isDefault
801 ? `Cannot move default Prisma database '${database.name}' from project '${database.project.id}' to '${projectId}' because the old default cannot be deleted. Promote another database in the original project first, then retry the move.`
802 : `Prisma database '${database.name}' belongs to project '${database.project.id}', not requested project '${projectId}'. Refusing to claim convergence; replace the database.`,
803 ),
804 );
805 }
806 if (database.region?.id !== region) {
807 return yield* Effect.fail(
808 new Error(
809 `Prisma database '${database.name}' is in immutable region '${database.region?.id ?? "unknown"}', not requested region '${region}'. Refusing to claim convergence; replace the database.`,
810 ),
811 );
812 }
813 if (!sourceMatches(database.source, news.source)) {
814 return yield* Effect.fail(
815 new Error(
816 `Prisma database '${database.name}' has immutable source ${JSON.stringify(database.source)}, not requested source ${JSON.stringify(news.source ?? { type: "empty" })}. Refusing to claim convergence; replace the database.`,
817 ),
818 );
819 }
820 if (
821 database.isDefault === true &&
822 (news.isDefault ?? false) === false
823 ) {
824 return yield* Effect.fail(
825 new Error(
826 `Cannot demote default Prisma database '${database.name}' directly because the Management API has no demotion operation. Promote another database in project '${projectId}' first, then retry this deployment.`,
827 ),
828 );
829 }
830
831 const ownedGeneratedIdentity =
832 news.name === undefined && database.name === name;
833
834 const desired = { ...news, name };
835 const needsPatch =
836 database.name !== name ||
837 (yield* branchNeedsSync(projectId, database, desired));
838 if (needsPatch) {
839 // Omitted branch props preserve the attachment; null is rejected.
840 database = (yield* updateDatabase({
841 databaseId: database.id,
842 name,
843 branchId: attach.branchId,
844 branchGitName: attach.branchGitName,
845 })).data;
846 }
847
848 const persistedSecrets =
849 output?.databaseId === database.id ? output : undefined;
850 const knownSecrets = mergeConnectionSecrets(secrets, {
851 directConnectionString: persistedSecrets?.directConnectionString,
852 pooledConnectionString: persistedSecrets?.pooledConnectionString,
853 accelerateConnectionString:
854 persistedSecrets?.accelerateConnectionString,
855 host: persistedSecrets?.host,
856 user: persistedSecrets?.user,
857 password: persistedSecrets?.password,
858 });
859 if (
860 recoverCreateSecrets ||
861 (ownedGeneratedIdentity &&
862 !hasCanonicalConnectionSecrets(knownSecrets)) ||
863 olds !== undefined ||
864 news.rotateCredentialsOnAdopt === true
865 ) {
866 const recovered = yield* recoverDatabaseConnectionSecrets(
867 database,
868 knownSecrets,
869 );
870 database = recovered.database;
871 return attrsFrom(database, recovered.secrets);
872 }
873 return attrsFrom(database, knownSecrets);
874 }),
875 delete: Effect.fn(function* ({ output }) {
876 if (isPrismaDevId(output.databaseId)) return;
877 const database = yield* getDatabase({
878 databaseId: output.databaseId,
879 }).pipe(
880 Effect.map((response) => response.data),
881 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
882 );
883 if (!database) return;
884 if (database.isDefault) {
885 return yield* Effect.fail(
886 new Error(
887 `Cannot delete default Prisma database '${database.name ?? output.databaseId}' directly. Promote another database first, or delete the owning Prisma.Project so the API can remove its project-owned default database.`,
888 ),
889 );
890 }
891 yield* deleteDatabase({
892 databaseId: output.databaseId,
893 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
894 }),
895 };
896 }),
897 );
898
899type PrismaDevDatabaseRequirements = ChildProcessSpawner | Path.Path | Scope;
900
901const ProviderLocal = () =>
902 Provider.succeed<
903 Database,
904 never,
905 never,
906 never,
907 PrismaDevDatabaseRequirements
908 >(Database, {
909 stables: ["databaseId"],
910 list: () => Effect.succeed([]),
911 diff: Effect.fn(function* () {
912 return { action: "update" } as const;
913 }),
914 read: Effect.fn(function* ({ output }) {
915 return output;
916 }),
917 reconcile: Effect.fn(function* ({ id, news, output }) {
918 const databaseId = output?.databaseId ?? devId("database", id);
919 const local = yield* ensurePrismaDevDatabase(databaseId, news.dev);
920 return {
921 databaseId,
922 databaseName: news.name ?? id,
923 projectId:
924 attrOrString(news.project, "projectId") ?? devId("project", id),
925 status: "ready",
926 region: news.region ?? "us-east-1",
927 isDefault: news.isDefault ?? false,
928 branchId: news.branchId ?? null,
929 defaultConnectionId: devId("connection", id),
930 createdAt: output?.createdAt ?? DEV_TIMESTAMP,
931 directConnectionString: local?.directConnectionString,
932 pooledConnectionString: local?.pooledConnectionString,
933 accelerateConnectionString: local?.accelerateConnectionString,
934 host: local?.host,
935 user: local?.user,
936 password: local?.password,
937 } satisfies Database["Attributes"];
938 }),
939 delete: Effect.fn(function* ({ output }) {
940 yield* closePrismaDevDatabase(output.databaseId);
941 }),
942 });
943
944export const DatabaseProvider = () =>
945 ProviderLayer.dual(Database, {
946 local: () => ProviderLocal(),
947 live: () => ProviderLive(),
948 });