| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Schedule from "effect/Schedule"; |
| 3 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | import { Unowned } from "../AdoptPolicy.ts"; |
| 6 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 7 | import * as Provider from "../Provider.ts"; |
| 8 | import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 9 | import type { Scope } from "effect/Scope"; |
| 10 | import type * as Path from "effect/Path"; |
| 11 | import { |
| 12 | closePrismaDevDatabase, |
| 13 | ensurePrismaDevDatabase, |
| 14 | } from "./PrismaDevDatabase.ts"; |
| 15 | import { DEV_TIMESTAMP, attrOrString, devId } from "./Internal/DevStub.ts"; |
| 16 | import * as ProviderLayer from "../Local/ProviderLayer.ts"; |
| 17 | import { Resource } from "../Resource.ts"; |
| 18 | import { |
| 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"; |
| 30 | import { Retry } from "@distilled.cloud/prisma"; |
| 31 | import { extractConnectionSecrets } from "./Client.ts"; |
| 32 | import type { Project } from "./Project.ts"; |
| 33 | import { |
| 34 | hasCanonicalConnectionSecrets, |
| 35 | mergeConnectionSecrets, |
| 36 | recoverDatabaseConnectionSecrets, |
| 37 | } from "./Internal/DatabaseSecrets.ts"; |
| 38 | import type { Providers } from "./Providers.ts"; |
| 39 | import { |
| 40 | concreteIdsChanged, |
| 41 | isInputObject, |
| 42 | isPrismaDevId, |
| 43 | resolveProjectId, |
| 44 | unresolvedProjectIdOf, |
| 45 | } from "./Refs.ts"; |
| 46 | import { |
| 47 | type ObservedProjectDatabase, |
| 48 | type ObservedSource, |
| 49 | narrowDatabaseSource, |
| 50 | } from "./Internal/Observed.ts"; |
| 51 | import type { |
| 52 | DatabaseSourceInput, |
| 53 | PrismaDatabaseRegionId, |
| 54 | PrismaRegionId, |
| 55 | PrismaSecretConnection, |
| 56 | } from "./Types.ts"; |
| 57 | import { PrismaPaginationError } from "./Internal/Pagination.ts"; |
| 58 | |
| 59 | export 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 | |
| 127 | export 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 | |
| 188 | export 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 | */ |
| 287 | export const Database = Resource<Database>("Prisma.Database"); |
| 288 | |
| 289 | const 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`). |
| 294 | const 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 | |
| 320 | const 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 | |
| 342 | const 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 | |
| 356 | class GeneratedDatabaseNotVisible extends Error {} |
| 357 | |
| 358 | const generatedDatabaseRecoverySchedule = Schedule.max([ |
| 359 | Schedule.exponential("250 millis"), |
| 360 | Schedule.recurs(6), |
| 361 | ]); |
| 362 | |
| 363 | const 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 | |
| 383 | const 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 | |
| 397 | const 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 | |
| 416 | const stripDatabaseIdPrefix = (databaseId: string) => |
| 417 | databaseId.startsWith("db_") ? databaseId.slice(3) : databaseId; |
| 418 | |
| 419 | const 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 | |
| 438 | const sourceMatches = ( |
| 439 | observed: ObservedSource | null, |
| 440 | desired: DatabaseSourceInput | undefined, |
| 441 | ) => |
| 442 | deepEqual( |
| 443 | normalizeDatabaseSource(narrowDatabaseSource(observed)), |
| 444 | normalizeDatabaseSource(desired), |
| 445 | ); |
| 446 | |
| 447 | const desiredSourcesMatch = ( |
| 448 | left: DatabaseSourceInput | undefined, |
| 449 | right: DatabaseSourceInput | undefined, |
| 450 | ) => deepEqual(normalizeDatabaseSource(left), normalizeDatabaseSource(right)); |
| 451 | |
| 452 | const 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 | |
| 466 | const 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 | |
| 487 | const 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 | |
| 505 | const 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 | |
| 521 | const 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 | |
| 547 | const 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 | |
| 899 | type PrismaDevDatabaseRequirements = ChildProcessSpawner | Path.Path | Scope; |
| 900 | |
| 901 | const 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 | |
| 944 | export const DatabaseProvider = () => |
| 945 | ProviderLayer.dual(Database, { |
| 946 | local: () => ProviderLocal(), |
| 947 | live: () => ProviderLive(), |
| 948 | }); |