1import * as Effect from "effect/Effect";
2import * as Duration from "effect/Duration";
3import * as Exit from "effect/Exit";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Redacted from "effect/Redacted";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14import type { HttpClientResponse } from "effect/http/HttpClientResponse";
15import * as ChildProcess from "effect/process/ChildProcess";
16import type { ChildProcessHandle } from "effect/process/ChildProcessSpawner";
17import type * as rolldown from "rolldown";
18import { AlchemyContext } from "../AlchemyContext.ts";
19import { Unowned } from "../AdoptPolicy.ts";
20import * as Bundle from "../Bundle/Bundle.ts";
21import { findCwdForBundle } from "../Bundle/TempRoot.ts";
22import { createPhysicalName } from "../PhysicalName.ts";
23import { isResolved } from "../Diff.ts";
24import { HttpServer, type HttpEffect } from "../Http.ts";
25import type { InputProps } from "../Input.ts";
26import * as Output from "../Output.ts";
27import { Platform, type Main, type PlatformProps } from "../Platform.ts";
28import * as Provider from "../Provider.ts";
29import * as ProviderLayer from "../Local/ProviderLayer.ts";
30import { Resource, type ResourceBinding } from "../Resource.ts";
31import { RuntimeContext } from "../RuntimeContext.ts";
32import type * as Server from "../Server/index.ts";
33import { Stack } from "../Stack.ts";
34import { sha256Object } from "../Util/sha256.ts";
35import { Retry } from "@distilled.cloud/prisma";
36import {
37 type GetServicesResponse,
38 type GetEnvironmentVariablesResponse,
39 type GetProjectBranchesResponse,
40 deleteEnvironmentVariable,
41 getServices,
42 getService,
43 getBranch,
44 getEnvironmentVariables,
45 getProjectBranches,
46 updateService,
47 updateEnvironmentVariable,
48 createService,
49 createServiceDeployment,
50 createServiceRollback,
51 createEnvironmentVariable,
52} from "@distilled.cloud/prisma/management";
53import {
54 runBuildCommand,
55 runComputeAutoBuild,
56 runComputeStaticBuild,
57 type ComputeAutoBuildFramework,
58} from "./ComputeBuild.ts";
59import { createComputeArchive, normalizeEntrypoint } from "./ComputeArchive.ts";
60import {
61 destroyApp,
62 destroyDeployment,
63 toDeploymentUrl,
64 waitForDeploymentStatus,
65} from "./ComputeLifecycle.ts";
66import {
67 startDeploymentIdempotent,
68 stopDeploymentIdempotent,
69} from "./Internal/DeploymentActions.ts";
70import { ensureAppImmutableIdentity } from "./Internal/AppIdentity.ts";
71import {
72 promoteAppObserved,
73 waitForAppDeploymentTarget,
74} from "./Internal/AppPromotion.ts";
75import { normalizeBundleFilePath } from "./Internal/BundlePaths.ts";
76import { aggregateCleanupFailure } from "./Internal/CleanupFailure.ts";
77import { ensureDeploymentMembership } from "./Internal/DeploymentIdentity.ts";
78import { observeDeployment } from "./Internal/DeploymentObserve.ts";
79import { tailDeploymentLogs } from "./PrismaLogs.ts";
80import type { Project } from "./Project.ts";
81import type { Providers } from "./Providers.ts";
82import {
83 concreteIdsChanged,
84 isInputObject,
85 isPrismaDevId,
86 resolveProjectId,
87 unresolvedProjectIdOf,
88} from "./Refs.ts";
89import type { PrismaRegionId } from "./Types.ts";
90import type {
91 ObservedApp,
92 ObservedDeployment,
93 ObservedEnvironmentVariable,
94} from "./Internal/Observed.ts";
95import { PrismaPaginationError } from "./Internal/Pagination.ts";
96import { readUploadArtifact, uploadArtifact } from "./Deployment.ts";
97
98const ComputeTypeId = "Prisma.Compute" as const;
99type ComputeTypeId = typeof ComputeTypeId;
100
101export interface ComputeCommandBuild {
102 /**
103 * Shell command that creates the deployable output directory.
104 */
105 command: string;
106 /**
107 * Working directory for the build command.
108 *
109 * @default path
110 */
111 cwd?: string;
112 /**
113 * Build output directory, relative to `cwd`.
114 */
115 outdir: string;
116 /**
117 * Entrypoint inside `outdir`.
118 */
119 entrypoint?: string;
120 /**
121 * Environment variables supplied to the build command.
122 * Ambient `PRISMA_SERVICE_TOKEN` and `PRISMA_API_TOKEN` credentials are not
123 * inherited; include one here explicitly only when the application build
124 * genuinely needs Prisma Management API access.
125 *
126 * Plain strings are persisted in Alchemy state. Wrap secrets with
127 * `Redacted.make(secret)`; Prisma-side encryption does not protect a value
128 * already stored in Alchemy state.
129 *
130 * ```typescript
131 * env: { NPM_TOKEN: Redacted.make(process.env.NPM_TOKEN!) }
132 * ```
133 */
134 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
135 /**
136 * Maximum bytes retained from each build output stream.
137 *
138 * @default 1048576 (1 MiB)
139 */
140 outputLimitBytes?: number;
141 /**
142 * Maximum wall-clock time for the build command.
143 *
144 * @default 900 (15 minutes)
145 */
146 timeoutSeconds?: number;
147}
148
149export interface ComputeAutoBuild {
150 /**
151 * Auto-detect a Prisma Compute build strategy, or force one framework.
152 */
153 type: "auto";
154 /**
155 * Framework build strategy.
156 *
157 * @default "auto"
158 */
159 framework?: ComputeAutoBuildFramework;
160 /**
161 * Environment variables supplied to the build command.
162 * Ambient `PRISMA_SERVICE_TOKEN` and `PRISMA_API_TOKEN` credentials are not
163 * inherited; include one here explicitly only when the application build
164 * genuinely needs Prisma Management API access.
165 *
166 * Plain strings are persisted in Alchemy state. Wrap secrets with
167 * `Redacted.make(secret)`.
168 *
169 * ```typescript
170 * env: { NPM_TOKEN: Redacted.make(process.env.NPM_TOKEN!) }
171 * ```
172 */
173 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
174 /**
175 * Maximum bytes retained from each framework build output stream.
176 *
177 * @default 1048576 (1 MiB)
178 */
179 outputLimitBytes?: number;
180 /**
181 * Maximum wall-clock time for the framework build command.
182 *
183 * @default 900 (15 minutes)
184 */
185 timeoutSeconds?: number;
186}
187
188export interface ComputeStaticBuild {
189 /**
190 * Build a static site and package it with a production HTTP server.
191 */
192 type: "static";
193 /**
194 * Static output directory, relative to `cwd`.
195 */
196 outdir: string;
197 /**
198 * Optional shell command that creates the static output directory. Omit it
199 * when `outdir` already contains the files to deploy.
200 */
201 command?: string;
202 /**
203 * Working directory for the build command and `outdir`.
204 *
205 * @default path
206 */
207 cwd?: string;
208 /**
209 * HTML file served at the root and for SPA fallbacks.
210 *
211 * @default "index.html"
212 */
213 indexPage?: string;
214 /**
215 * Serve the index page when no static file matches the request.
216 *
217 * @default false
218 */
219 spa?: boolean;
220 /**
221 * Environment variables supplied to the build command.
222 * Ambient `PRISMA_SERVICE_TOKEN` and `PRISMA_API_TOKEN` credentials are not
223 * inherited; include one here explicitly only when the application build
224 * genuinely needs Prisma Management API access.
225 *
226 * Plain strings are persisted in Alchemy state. Wrap secrets with
227 * `Redacted.make(secret)`.
228 *
229 * ```typescript
230 * env: { NPM_TOKEN: Redacted.make(process.env.NPM_TOKEN!) }
231 * ```
232 */
233 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
234 /**
235 * Maximum bytes retained from each build output stream.
236 *
237 * @default 1048576 (1 MiB)
238 */
239 outputLimitBytes?: number;
240 /**
241 * Maximum wall-clock time for the build command.
242 *
243 * @default 900 (15 minutes)
244 */
245 timeoutSeconds?: number;
246}
247
248export type ComputeBuild =
249 | ComputeCommandBuild
250 | ComputeAutoBuild
251 | ComputeStaticBuild;
252
253export interface ComputeBundleOptions {
254 /**
255 * Rolldown input options for effect-native Compute bundles.
256 */
257 input?: Partial<rolldown.InputOptions>;
258 /**
259 * Rolldown output options for effect-native Compute bundles.
260 */
261 output?: Partial<rolldown.OutputOptions>;
262 /**
263 * Additional Alchemy bundle options for effect-native Compute bundles.
264 */
265 extra?: Bundle.BundleExtraOptions;
266}
267
268export interface ComputeDev {
269 /**
270 * Local command to run during `alchemy dev`.
271 */
272 command?: string;
273 /**
274 * Working directory for the dev command.
275 *
276 * @default path
277 */
278 cwd?: string;
279 /**
280 * Local development port.
281 */
282 port?: number;
283 /**
284 * Explicit local URL to expose in the resource output.
285 */
286 url?: string;
287 /**
288 * Extra environment variables for the dev command.
289 * Plain strings are persisted in Alchemy state. Wrap secrets with
290 * `Redacted.make(secret)`.
291 */
292 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
293}
294
295export interface ComputeHealthCheck {
296 /**
297 * Absolute application path to probe after the deployment starts and after
298 * promotion.
299 *
300 * @example "/api/health"
301 */
302 path: string;
303 /**
304 * Exact HTTP status codes that indicate readiness. When omitted, any 2xx
305 * response is healthy.
306 *
307 * @default Any status from 200 through 299
308 */
309 statusCodes?: readonly number[];
310}
311
312export interface ComputeProps extends PlatformProps {
313 /**
314 * Project ID or `project.projectId` output that owns the App.
315 */
316 project: string | Project;
317 /**
318 * App display name. If omitted, Alchemy generates a stable physical name.
319 */
320 appName?: string;
321 /**
322 * Region where the App is placed.
323 *
324 * @default The project's default region, falling back to "us-east-1"
325 */
326 regionId?: PrismaRegionId;
327 /**
328 * Branch ID to attach the App to. Mutually exclusive with branchGitName.
329 * If both branch fields are omitted, Alchemy attaches to the project's
330 * current default branch.
331 */
332 branchId?: string;
333 /**
334 * Branch git name to attach the App to. Mutually exclusive with branchId.
335 *
336 * @default The project's current default branch
337 */
338 branchGitName?: string;
339 /**
340 * Application directory used for pre-built artifacts and build commands.
341 *
342 * @default "."
343 */
344 path?: string;
345 /**
346 * Additional files or directories to exclude from path deployments. Static
347 * build patterns are relative to the configured output directory; other
348 * patterns are artifact-relative. `*` and `**` wildcards are supported;
349 * absolute paths, parent segments, and negated patterns are rejected.
350 * `.env*`, `.git`, and `.alchemy` are always excluded. Static index pages
351 * must remain present after exclusions.
352 */
353 archiveIgnore?: readonly string[];
354 /**
355 * Entrypoint relative to the deployed artifact directory.
356 * If omitted, Alchemy reads `package.json#main`.
357 */
358 entrypoint?: string;
359 /**
360 * Entry module for an effect-native Compute app.
361 *
362 * This is required when you pass an inline Effect implementation to
363 * `Prisma.Compute`, and ignored for external path deployments.
364 */
365 main?: string;
366 /**
367 * Exported symbol inside `main` for effect-native Compute apps.
368 *
369 * @default "default"
370 */
371 handler?: string;
372 /**
373 * Bundler options for effect-native Compute apps.
374 */
375 bundle?: ComputeBundleOptions;
376 /**
377 * Effect-native runtime exports populated by the Platform constructor.
378 *
379 * @internal
380 */
381 exports?: string[] | Record<string, unknown>;
382 /**
383 * Build command and output directory. Set to `"auto"` or `{ type: "auto" }`
384 * to use Prisma Compute-style framework detection for Next.js, Nuxt, Astro,
385 * TanStack Start, Vite, or Bun. Set to `false` to upload `path` as a
386 * pre-built artifact.
387 */
388 build?: ComputeBuild | false | "auto";
389 /**
390 * Path to a pre-created `tar.gz` artifact file. When supplied, Alchemy reads
391 * and uploads it directly.
392 */
393 artifactPath?: string;
394 /**
395 * HTTP port exposed by the application.
396 *
397 * @default 8080
398 */
399 port?: number;
400 /**
401 * Runtime environment variables to sync through Prisma's environment
402 * variable API before creating a new deployment. Set a value to `null`
403 * to delete that variable.
404 *
405 * Plain strings are persisted in Alchemy state. For secrets, use
406 * `Redacted.make(secret)`; encryption in the Prisma Management API does not
407 * protect a plain value already recorded in Alchemy state.
408 *
409 * The Management API exposes neither an idempotency key nor ownership
410 * metadata for environment variables, and reads return redacted values. If
411 * a process crashes after Prisma commits a create but before Alchemy saves
412 * its returned ID, the next deploy safely refuses to claim that natural-key
413 * match. Use standalone `Prisma.EnvironmentVariable` resources for critical
414 * keys that need independent lifecycle management and explicit adoption.
415 *
416 * ```typescript
417 * env: {
418 * LOG_LEVEL: "info",
419 * API_TOKEN: Redacted.make(process.env.API_TOKEN!),
420 * }
421 * ```
422 */
423 env?: Record<string, string | Redacted.Redacted<string> | null | undefined>;
424 /**
425 * Prisma environment variable class used by the `env` convenience property.
426 *
427 * @default "production"
428 */
429 envClass?: "production" | "preview";
430 /**
431 * Create the next deployment by reusing the previous code artifact.
432 *
433 * @default false
434 */
435 skipCodeUpload?: boolean;
436 /**
437 * Start the created/reused deployment.
438 * Set `skipPromote: true` when disabling start.
439 *
440 * @default true
441 */
442 start?: boolean;
443 /**
444 * Do not promote the deployment to the stable App endpoint.
445 *
446 * @default false
447 */
448 skipPromote?: boolean;
449 /**
450 * Delete the previously promoted deployment after the new one is promoted.
451 *
452 * @default false
453 */
454 destroyOldDeployment?: boolean;
455 /**
456 * Poll timeout while waiting for start/stop.
457 *
458 * @default 120
459 */
460 timeoutSeconds?: number;
461 /**
462 * Poll interval while waiting for start/stop.
463 *
464 * @default 1000
465 */
466 pollIntervalMs?: number;
467 /**
468 * Verify that Prisma's public preview/App URL has reached the edge after
469 * the Management API reports the deployment as running.
470 *
471 * @default true
472 */
473 verifyUrl?: boolean;
474 /**
475 * Optional application-level health check. Without this option, URL
476 * verification only waits for Prisma edge routing to stop returning its
477 * platform-level service-not-found response. With this option, Alchemy also
478 * sends a public GET to the preview URL before promotion and to the stable
479 * App URL afterward. Redirects are not followed. Each probe phase gets the
480 * full `urlReadinessTimeoutSeconds` budget.
481 *
482 * Health checks run only during cloud deployment, not `alchemy dev`, and
483 * cannot be combined with `verifyUrl: false` or `start: false`.
484 */
485 healthCheck?: ComputeHealthCheck;
486 /**
487 * Maximum time to wait for Prisma's public URL to stop returning the
488 * platform-level "Service not found" page.
489 *
490 * @default 60
491 */
492 urlReadinessTimeoutSeconds?: number;
493 /**
494 * Local development behavior for `alchemy dev`.
495 */
496 dev?: ComputeDev;
497}
498
499export interface Compute extends Resource<
500 ComputeTypeId,
501 ComputeProps,
502 {
503 /**
504 * Prisma App ID.
505 */
506 appId: string;
507 /**
508 * Prisma deployment ID created for the current deployment.
509 */
510 deploymentId: string | undefined;
511 /**
512 * Project ID that owns the app.
513 */
514 projectId: string;
515 /**
516 * App display name.
517 */
518 appName: string;
519 /**
520 * Region ID where the App is placed.
521 */
522 regionId: string;
523 /**
524 * Preview endpoint domain for the deployment.
525 */
526 deploymentEndpointDomain: string | undefined;
527 /**
528 * HTTPS URL for the deployment endpoint.
529 */
530 deploymentUrl: string | undefined;
531 /**
532 * Stable App endpoint domain after promotion.
533 */
534 appEndpointDomain: string | undefined;
535 /**
536 * Preferred URL for the app, local in dev or stable/preview in deploys.
537 */
538 url: string | undefined;
539 /**
540 * Whether the current deployment was promoted to the stable endpoint.
541 */
542 promoted: boolean;
543 /**
544 * Previously promoted deployment ID observed before deploy.
545 */
546 previousDeploymentId: string | null | undefined;
547 /**
548 * Action taken for the previous deployment.
549 */
550 previousDeploymentAction:
551 | "stopped"
552 | "destroyed"
553 | "still-active"
554 | null
555 | undefined;
556 /** Stable App endpoint readiness observed after promotion. */
557 readinessStatus?: "ready" | "pending" | "skipped";
558 /** Old deployment cleanup that will be retried before another generation. */
559 pendingDeploymentCleanup?: {
560 deploymentId: string;
561 action: "stop" | "destroy";
562 };
563 /**
564 * Environment variable keys managed by this Compute resource.
565 *
566 * This includes explicit `env` props and env vars added by bindings.
567 */
568 environmentKeys?: string[];
569 /**
570 * Environment variable IDs created by this Compute resource, keyed by
571 * variable name. IDs are the ownership boundary for shared branch/project
572 * environment-variable namespaces.
573 */
574 environmentVariableIds?: Record<string, string>;
575 /**
576 * Prisma environment class used for the managed environment variable keys.
577 */
578 environmentClass?: "production" | "preview";
579 /**
580 * Branch ID used for managed preview branch environment overrides, or null
581 * for project-level environment templates.
582 */
583 environmentBranchId?: string | null;
584 /**
585 * Fingerprint of the uploaded artifact/reused artifact inputs and the
586 * branch attachment that Prisma resolves environment variables from.
587 */
588 artifactHash: Redacted.Redacted<string> | undefined;
589 /**
590 * Whether the app output represents a local dev process.
591 */
592 local: boolean;
593 },
594 {
595 env?: Record<string, string | Redacted.Redacted<string> | null | undefined>;
596 },
597 Providers
598> {}
599
600export type ComputeRuntimeServices = Server.ProcessServices;
601
602export type ComputeShape = Main<ComputeRuntimeServices>;
603
604export interface ComputeRuntimeContext extends Server.ProcessContext {
605 readonly Type: ComputeTypeId;
606}
607
608export const isCompute = (value: unknown): value is Compute =>
609 typeof value === "object" &&
610 value !== null &&
611 "Type" in value &&
612 value.Type === ComputeTypeId;
613
614const hasEffectNativeComputeInput = (props: ComputeProps) =>
615 props.isExternal !== true &&
616 (props.main !== undefined || props.exports !== undefined);
617
618const isEffectNativeCompute = (props: ComputeProps) =>
619 hasEffectNativeComputeInput(props);
620
621/**
622 * Build and deploy an application to Prisma Compute.
623 *
624 * Prisma's create-deployment API exposes neither an idempotency key nor a
625 * caller-defined recovery key. If the API commits a deployment but its create
626 * response is lost before Alchemy persists the returned ID, that deployment
627 * can remain orphaned and a later deploy may create another one. Alchemy does
628 * not guess that the App's latest deployment is owned, because it could belong
629 * to another actor. Use a durable, locked state backend and inspect the App's
630 * deployment history after an interrupted create.
631 *
632 * ### Deploying an App
633 * **Example:** Deploy a directory with an entrypoint
634 * ```typescript
635 * const app = yield* Prisma.Compute("api", {
636 * project: project.projectId,
637 * path: "./apps/api",
638 * entrypoint: "server.ts",
639 * port: 3000,
640 * });
641 * ```
642 *
643 * **Example:** Deploy an Effect-native HTTP app
644 * ```typescript
645 * export default Prisma.Compute(
646 * "api",
647 * {
648 * project,
649 * appName: "api",
650 * main: import.meta.filename,
651 * port: 8080,
652 * },
653 * Effect.gen(function* () {
654 * return {
655 * fetch: Effect.succeed(HttpServerResponse.text("ok")),
656 * };
657 * }),
658 * );
659 * ```
660 *
661 * ### Bundling & Tree-shaking
662 * `main` is bundled with rolldown at deploy time. Unused code is
663 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
664 * pure so unused parts prune more aggressively. Your app is not
665 * marked pure.
666 *
667 * **Example:** Mark additional packages as pure
668 * Only list packages with no top-level side effects.
669 * ```typescript
670 * {
671 * main: "./src/app.ts",
672 * bundle: {
673 * extra: { pure: { packages: ["my-lib", "@my-scope/*"] } },
674 * },
675 * }
676 * ```
677 *
678 * **Example:** Turn it off
679 * ```typescript
680 * {
681 * main: "./src/app.ts",
682 * bundle: { extra: { pure: false } },
683 * }
684 * ```
685 *
686 * ### Runtime Bindings
687 * **Example:** Bind a Prisma Connection
688 * ```typescript
689 * export default Prisma.Compute(
690 * "api",
691 * {
692 * project,
693 * appName: "api",
694 * main: import.meta.filename,
695 * },
696 * Effect.gen(function* () {
697 * const db = yield* Prisma.Connect(connection);
698 * const sql = yield* SQL.Postgres({ url: db.databaseUrl });
699 *
700 * return {
701 * fetch: Effect.gen(function* () {
702 * const users = yield* sql`SELECT * FROM users`;
703 * return yield* HttpServerResponse.json(users);
704 * }),
705 * };
706 * }).pipe(Effect.provide(Prisma.ConnectBinding)),
707 * );
708 * ```
709 *
710 * **Example:** Build before upload and replace old versions
711 * ```typescript
712 * const app = yield* Prisma.Compute("api", {
713 * project: project.projectId,
714 * path: "./apps/api",
715 * build: {
716 * command: "bun build src/server.ts --target bun --outdir dist",
717 * outdir: "dist",
718 * entrypoint: "server.js",
719 * },
720 * port: 8080,
721 * env: {
722 * // Use this for a standalone Connection. A project's default database
723 * // is injected by Prisma without an explicit DATABASE_URL entry.
724 * DATABASE_URL: connection.databaseUrl,
725 * },
726 * destroyOldDeployment: true,
727 * });
728 * ```
729 *
730 * **Example:** Auto-build a framework app
731 * ```typescript
732 * const app = yield* Prisma.Compute("api", {
733 * project: project.projectId,
734 * path: "./apps/web",
735 * build: "auto",
736 * destroyOldDeployment: true,
737 * });
738 * ```
739 *
740 * **Example:** Deploy a static site
741 * ```typescript
742 * const web = yield* Prisma.Compute("web", {
743 * project,
744 * path: "./site",
745 * build: {
746 * type: "static",
747 * outdir: ".",
748 * },
749 * });
750 * ```
751 *
752 * **Example:** Deploy a prebuilt tar.gz artifact
753 * ```typescript
754 * const app = yield* Prisma.Compute("api", {
755 * project: project.projectId,
756 * artifactPath: "./dist/app.tar.gz",
757 * port: 8080,
758 * });
759 * ```
760 *
761 * ### Deployment Health
762 * **Example:** Require application readiness before promotion
763 * ```typescript
764 * const app = yield* Prisma.Compute("api", {
765 * project,
766 * path: "./apps/api",
767 * entrypoint: "server.ts",
768 * healthCheck: {
769 * path: "/api/health",
770 * // Defaults to any 2xx response when omitted.
771 * statusCodes: [200, 204],
772 * },
773 * });
774 * ```
775 *
776 * ### Local Development
777 * **Example:** Run locally during alchemy dev
778 * ```typescript
779 * const app = yield* Prisma.Compute("api", {
780 * project: project.projectId,
781 * path: "./apps/api",
782 * entrypoint: "server.ts",
783 * dev: {
784 * command: "bun run dev",
785 * port: 3000,
786 * },
787 * });
788 * ```
789 *
790 * @resource
791 * @product Compute
792 */
793export const Compute: Platform<
794 Compute,
795 ComputeRuntimeServices,
796 ComputeShape,
797 ComputeRuntimeContext
798> & {
799 <PropsReq = never>(
800 id: string,
801 props:
802 | InputProps<ComputeProps>
803 | Effect.Effect<InputProps<ComputeProps>, never, PropsReq>,
804 ): Effect.Effect<Compute, never, Providers | PropsReq>;
805} = Platform(ComputeTypeId, {
806 createRuntimeContext: (id): ComputeRuntimeContext => {
807 const runners: Effect.Effect<void, never, unknown>[] = [];
808 const env: Record<string, unknown> = {};
809 let runtimeContext: ComputeRuntimeContext;
810 const run: Server.ProcessContext["run"] = (effect) =>
811 Effect.sync(() => {
812 runners.push(effect);
813 });
814
815 const serve = <Req = never>(handler: HttpEffect<Req>) =>
816 Effect.sync(() => {
817 runners.push(
818 Effect.gen(function* () {
819 const httpServer = yield* Effect.serviceOption(HttpServer).pipe(
820 Effect.map(Option.getOrUndefined),
821 );
822 if (httpServer) {
823 yield* httpServer.serve(
824 handler.pipe(
825 Effect.provideService(RuntimeContext, runtimeContext),
826 ),
827 );
828 yield* Effect.never;
829 }
830 }).pipe(Effect.catch((error: unknown) => Effect.die(error))),
831 );
832 });
833
834 runtimeContext = {
835 Type: ComputeTypeId,
836 id,
837 env,
838 set: (bindingId: string, output: Output.Output) =>
839 Effect.sync(() => {
840 // Prisma env keys are validated to the uppercase POSIX shape
841 // ([A-Z_][A-Z0-9_]*), so the storage key is uppercased here. The
842 // runtime `get` receives the key this returns, so both halves
843 // agree without a second mapping.
844 const key = bindingId.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
845 env[key] = output.pipe(
846 Output.map((value) =>
847 Redacted.isRedacted(value)
848 ? Redacted.make(
849 JSON.stringify({
850 _tag: "Redacted",
851 value: Redacted.value(value),
852 }),
853 )
854 : JSON.stringify(value),
855 ),
856 );
857 return key;
858 }),
859 get: <T>(key: string) =>
860 // Runtime ConfigProvider lookups call back into RuntimeContext.get.
861 // Read the process env directly here to avoid re-entering that path.
862 Effect.sync(() => {
863 const value = process.env[key];
864 if (value === undefined) {
865 return undefined;
866 }
867 try {
868 const parsed = JSON.parse(value);
869 if (
870 parsed !== null &&
871 typeof parsed === "object" &&
872 (parsed as { _tag?: unknown })._tag === "Redacted" &&
873 "value" in (parsed as object)
874 ) {
875 return Redacted.make((parsed as { value: string }).value) as T;
876 }
877 return parsed as T;
878 } catch {
879 return value as T;
880 }
881 }),
882 run,
883 serve,
884 exports: Effect.sync(() => ({
885 default: Effect.all(
886 runners.map((effect) =>
887 Effect.forever(
888 effect.pipe(
889 Effect.tapError((error) => Effect.logError(error)),
890 Effect.ignore,
891 ),
892 ),
893 ),
894 { concurrency: "unbounded" },
895 ),
896 })),
897 };
898 return runtimeContext;
899 },
900});
901
902const devProcesses = new Map<string, ChildProcessHandle>();
903
904const stopTrackedDevProcess = Effect.fn(function* (processKey: string) {
905 const existing = devProcesses.get(processKey);
906 if (!existing) return;
907
908 const running = yield* existing.isRunning;
909 if (running) {
910 // Keep the handle tracked when termination fails so a later reconcile or
911 // destroy can retry instead of losing the only reference to the process.
912 yield* existing.kill();
913 }
914 devProcesses.delete(processKey);
915});
916
917const projectConsistencySchedule = Schedule.max([
918 Schedule.exponential(Duration.millis(500)),
919 Schedule.recurs(6),
920]);
921
922// Only project/branch/app reads and the app create flow through the
923// provisioning-consistency retries, so a NotFound there is exactly the
924// post-create lag the schedule exists to absorb.
925// The retried effects mix distilled's tagged operation errors with plain
926// `Error` failures of their own (an unresolvable branch, a foreign App), so
927// narrow before reading the tag.
928const isAppProvisioningNotFound = (error: Error | { readonly _tag: string }) =>
929 "_tag" in error && error._tag === "NotFound";
930
931// Distilled emits the cursor-paginated list operations as plain ops, so
932// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
933const listBranches = (projectId: string, gitName?: string) =>
934 Effect.gen(function* () {
935 const branches: GetProjectBranchesResponse["data"][number][] = [];
936 let cursor: string | undefined;
937 while (true) {
938 const page = yield* getProjectBranches({
939 projectId,
940 limit: 100,
941 ...(gitName === undefined ? {} : { gitName }),
942 ...(cursor === undefined ? {} : { cursor }),
943 });
944 branches.push(...page.data);
945 const nextCursor = page.pagination.nextCursor;
946 if (!page.pagination.hasMore) break;
947 if (nextCursor === null) {
948 return yield* Effect.fail(
949 new PrismaPaginationError({
950 message:
951 "Invalid Prisma Management API pagination response from getProjectBranches: hasMore was true without a non-empty nextCursor",
952 }),
953 );
954 }
955 cursor = nextCursor;
956 }
957 return branches;
958 });
959
960const listApps = (projectId: string) =>
961 Effect.gen(function* () {
962 const apps: GetServicesResponse["data"][number][] = [];
963 let cursor: string | undefined;
964 while (true) {
965 const page = yield* getServices(
966 cursor === undefined
967 ? { projectId, limit: 100 }
968 : { projectId, limit: 100, cursor },
969 );
970 apps.push(...page.data);
971 const nextCursor = page.pagination.nextCursor;
972 if (!page.pagination.hasMore) break;
973 if (nextCursor === null) {
974 return yield* Effect.fail(
975 new PrismaPaginationError({
976 message:
977 "Invalid Prisma Management API pagination response from getServices: hasMore was true without a non-empty nextCursor",
978 }),
979 );
980 }
981 cursor = nextCursor;
982 }
983 return apps;
984 });
985
986const desiredComputeBranchId = Effect.fn(function* (
987 projectId: string,
988 props: Pick<ComputeProps, "branchId" | "branchGitName">,
989) {
990 if (props.branchId !== undefined && !isPrismaDevId(props.branchId)) {
991 return { resolved: true as const, id: props.branchId };
992 }
993 const branches = yield* listBranches(projectId, props.branchGitName);
994 const matchingBranches =
995 props.branchGitName === undefined
996 ? branches.filter((branch) => branch.isDefault)
997 : branches;
998 if (matchingBranches.length > 1) {
999 return yield* Effect.fail(
1000 new Error(
1001 props.branchGitName === undefined
1002 ? `Prisma returned multiple default branches for project '${projectId}'; refusing an ambiguous Compute App match.`
1003 : `Prisma returned multiple branches named '${props.branchGitName}' in project '${projectId}'; refusing an ambiguous Compute App match.`,
1004 ),
1005 );
1006 }
1007 const branch = matchingBranches[0];
1008 return branch
1009 ? { resolved: true as const, id: branch.id }
1010 : { resolved: false as const };
1011});
1012
1013const createAppName = (id: string, appName: string | undefined) =>
1014 appName === undefined ? createPhysicalName({ id }) : Effect.succeed(appName);
1015
1016const findApp = Effect.fn(function* (
1017 projectId: string,
1018 appName: string,
1019 props: Pick<ComputeProps, "branchId" | "branchGitName">,
1020) {
1021 const candidates = (yield* listApps(projectId)).filter(
1022 (app) => app.name === appName,
1023 );
1024 if (candidates.length === 0) return undefined;
1025 const branch = yield* desiredComputeBranchId(projectId, props);
1026 if (!branch.resolved) return undefined;
1027 const matches = candidates.filter((app) => app.branchId === branch.id);
1028 if (matches.length > 1) {
1029 return yield* Effect.fail(
1030 new Error(
1031 `Prisma returned multiple Apps named '${appName}' on branch '${branch.id}' in project '${projectId}'; refusing an ambiguous ownership match.`,
1032 ),
1033 );
1034 }
1035 return matches[0];
1036});
1037
1038const createApp = (
1039 projectId: string,
1040 props: ComputeProps & { appName: string },
1041 branchId: string,
1042) =>
1043 createService({
1044 projectId,
1045 displayName: props.appName,
1046 branchId,
1047 ...(props.regionId === undefined ? {} : { regionId: props.regionId }),
1048 }).pipe(
1049 // A replayed create would make a second App; the retry policy cannot
1050 // see the request, so opt out explicitly.
1051 Retry.none,
1052 Effect.map((response) => response.data),
1053 );
1054
1055const plainEnv = (
1056 env: Record<
1057 string,
1058 string | Redacted.Redacted<string> | null | undefined
1059 > = {},
1060) =>
1061 Object.fromEntries(
1062 Object.entries(env).flatMap(([key, value]) =>
1063 value === undefined
1064 ? []
1065 : [[key, Redacted.isRedacted(value) ? Redacted.value(value) : value]],
1066 ),
1067 ) as Record<string, string | null>;
1068
1069const persistedArtifactHashValue = (
1070 value: Redacted.Redacted<string> | string | undefined,
1071) => (Redacted.isRedacted(value) ? Redacted.value(value) : value);
1072
1073const managedEnvKeys = (
1074 env: Record<
1075 string,
1076 string | Redacted.Redacted<string> | null | undefined
1077 > = {},
1078) =>
1079 Object.entries(plainEnv(env))
1080 .flatMap(([key, value]) => (value === null ? [] : [key]))
1081 .sort();
1082
1083const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
1084const ENV_VALUE_MAX_BYTES = 8 * 1024;
1085const EXPORT_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1086
1087const computeEnvValue = (value: string | Redacted.Redacted<string>) =>
1088 Redacted.isRedacted(value) ? Redacted.value(value) : value;
1089
1090const validateComputeEnvironmentKey = (key: string) =>
1091 Effect.gen(function* () {
1092 if (key.length < 1 || key.length > 256 || !ENV_KEY_PATTERN.test(key)) {
1093 return yield* Effect.fail(
1094 new Error(
1095 `Prisma environment variable key '${key}' must match POSIX env-var key shape: [A-Z_][A-Z0-9_]* and be at most 256 characters.`,
1096 ),
1097 );
1098 }
1099 });
1100
1101const validateComputeEnvironmentWrite = (
1102 key: string,
1103 value: string | Redacted.Redacted<string>,
1104) =>
1105 Effect.gen(function* () {
1106 yield* validateComputeEnvironmentKey(key);
1107 const raw = computeEnvValue(value);
1108 if (raw.length === 0) {
1109 return yield* Effect.fail(
1110 new Error(
1111 `Prisma environment variable '${key}' value must be non-empty.`,
1112 ),
1113 );
1114 }
1115 const byteLength = yield* Effect.sync(
1116 () => new TextEncoder().encode(raw).byteLength,
1117 );
1118 if (byteLength > ENV_VALUE_MAX_BYTES) {
1119 return yield* Effect.fail(
1120 new Error(
1121 `Prisma environment variable '${key}' value exceeds ${ENV_VALUE_MAX_BYTES} bytes.`,
1122 ),
1123 );
1124 }
1125 });
1126
1127const validateComputeHealthCheck = (
1128 healthCheck: ComputeHealthCheck | undefined,
1129) =>
1130 Effect.gen(function* () {
1131 if (healthCheck === undefined) return;
1132 if (
1133 !healthCheck.path.startsWith("/") ||
1134 healthCheck.path.startsWith("//") ||
1135 healthCheck.path.includes("\\") ||
1136 /\s/.test(healthCheck.path)
1137 ) {
1138 return yield* Effect.fail(
1139 new Error(
1140 "healthCheck.path must be an absolute application path beginning with one '/'.",
1141 ),
1142 );
1143 }
1144 if (
1145 healthCheck.statusCodes !== undefined &&
1146 healthCheck.statusCodes.length === 0
1147 ) {
1148 return yield* Effect.fail(
1149 new Error("healthCheck.statusCodes must contain at least one status."),
1150 );
1151 }
1152 for (const status of healthCheck.statusCodes ?? []) {
1153 if (!Number.isInteger(status) || status < 100 || status > 599) {
1154 return yield* Effect.fail(
1155 new Error(
1156 "healthCheck.statusCodes must contain HTTP status integers from 100 through 599.",
1157 ),
1158 );
1159 }
1160 }
1161 });
1162
1163const validateComputeProps = (props: ComputeProps) =>
1164 Effect.gen(function* () {
1165 yield* validateComputeHealthCheck(props.healthCheck);
1166 if (props.healthCheck !== undefined && props.verifyUrl === false) {
1167 return yield* Effect.fail(
1168 new Error("healthCheck cannot be combined with verifyUrl: false."),
1169 );
1170 }
1171 if (props.healthCheck !== undefined && props.start === false) {
1172 return yield* Effect.fail(
1173 new Error("healthCheck requires start to be enabled."),
1174 );
1175 }
1176 for (const [name, value] of [
1177 ["timeoutSeconds", props.timeoutSeconds],
1178 ["pollIntervalMs", props.pollIntervalMs],
1179 ["urlReadinessTimeoutSeconds", props.urlReadinessTimeoutSeconds],
1180 ] as const) {
1181 if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
1182 return yield* Effect.fail(
1183 new Error(`${name} must be a positive finite number.`),
1184 );
1185 }
1186 }
1187 for (const [name, value] of [
1188 ["port", props.port],
1189 ["dev.port", props.dev?.port],
1190 ] as const) {
1191 if (
1192 value !== undefined &&
1193 (!Number.isInteger(value) || value < 1 || value > 65_535)
1194 ) {
1195 return yield* Effect.fail(
1196 new Error(`${name} must be an integer between 1 and 65535.`),
1197 );
1198 }
1199 }
1200 for (const [key, value] of Object.entries(props.env ?? {})) {
1201 if (value === undefined) continue;
1202 if (value === null) {
1203 yield* validateComputeEnvironmentKey(key);
1204 } else {
1205 yield* validateComputeEnvironmentWrite(key, value);
1206 }
1207 }
1208 if ((props.skipPromote ?? false) && (props.destroyOldDeployment ?? false)) {
1209 return yield* Effect.fail(
1210 new Error(
1211 "destroyOldDeployment cannot be combined with skipPromote because the previous deployment stays active when promotion is skipped.",
1212 ),
1213 );
1214 }
1215 if (props.start === false && !(props.skipPromote ?? false)) {
1216 return yield* Effect.fail(
1217 new Error(
1218 "start: false requires skipPromote: true because Prisma Compute promotion requires a running deployment.",
1219 ),
1220 );
1221 }
1222 if (props.branchId !== undefined && props.branchGitName !== undefined) {
1223 return yield* Effect.fail(
1224 new Error("branchId and branchGitName are mutually exclusive."),
1225 );
1226 }
1227 if (props.branchId === null || props.branchGitName === null) {
1228 return yield* Effect.fail(
1229 new Error(
1230 "Prisma.Compute requires an attached branch because deployment creation resolves environment variables from the App branch. Omit branchGitName to use the project's default branch, or set branchId/branchGitName.",
1231 ),
1232 );
1233 }
1234 if ((props.skipCodeUpload ?? false) && props.artifactPath !== undefined) {
1235 return yield* Effect.fail(
1236 new Error("skipCodeUpload cannot be combined with artifactPath."),
1237 );
1238 }
1239 if ((props.skipCodeUpload ?? false) && props.build !== undefined) {
1240 return yield* Effect.fail(
1241 new Error("skipCodeUpload cannot be combined with build."),
1242 );
1243 }
1244 if (hasEffectNativeComputeInput(props)) {
1245 const handler = props.handler ?? "default";
1246 if (handler !== "default" && !EXPORT_IDENTIFIER_PATTERN.test(handler)) {
1247 return yield* Effect.fail(
1248 new Error(
1249 "Effect-native Prisma Compute handler must be `default` or a valid JavaScript export identifier.",
1250 ),
1251 );
1252 }
1253 if (props.main === undefined) {
1254 return yield* Effect.fail(
1255 new Error(
1256 "Effect-native Prisma Compute apps require `main`. Set `main: import.meta.filename`.",
1257 ),
1258 );
1259 }
1260 if (props.artifactPath !== undefined) {
1261 return yield* Effect.fail(
1262 new Error(
1263 "Effect-native Prisma Compute apps cannot use artifactPath.",
1264 ),
1265 );
1266 }
1267 if (props.build !== undefined) {
1268 return yield* Effect.fail(
1269 new Error("Effect-native Prisma Compute apps cannot use build."),
1270 );
1271 }
1272 if (props.skipCodeUpload) {
1273 return yield* Effect.fail(
1274 new Error(
1275 "Effect-native Prisma Compute apps cannot skip code upload.",
1276 ),
1277 );
1278 }
1279 }
1280 });
1281
1282const processEnv = (
1283 env: Record<
1284 string,
1285 string | Redacted.Redacted<string> | null | undefined
1286 > = {},
1287) =>
1288 Object.fromEntries(
1289 Object.entries(env).flatMap(([key, value]) =>
1290 value === undefined || value === null
1291 ? []
1292 : [[key, Redacted.isRedacted(value) ? Redacted.value(value) : value]],
1293 ),
1294 ) as Record<string, string>;
1295
1296const isPrismaEdgeServiceNotFound = (status: number, body: string) =>
1297 status === 404 &&
1298 (body.includes("There is no service on this URL") ||
1299 body.includes("<title>Service not found</title>"));
1300
1301const URL_READINESS_BODY_PREFIX_BYTES = 64 * 1024;
1302
1303const readResponseBodyPrefix = (response: HttpClientResponse) =>
1304 Effect.gen(function* () {
1305 const chunks: Uint8Array[] = [];
1306 let bytes = 0;
1307 yield* Stream.runForEachWhile(response.stream, (chunk) =>
1308 Effect.sync(() => {
1309 const remaining = URL_READINESS_BODY_PREFIX_BYTES - bytes;
1310 if (remaining <= 0) return false;
1311 const kept =
1312 chunk.byteLength > remaining ? chunk.slice(0, remaining) : chunk;
1313 chunks.push(kept);
1314 bytes += kept.byteLength;
1315 return bytes < URL_READINESS_BODY_PREFIX_BYTES;
1316 }),
1317 );
1318 const prefix = new Uint8Array(bytes);
1319 let offset = 0;
1320 for (const chunk of chunks) {
1321 prefix.set(chunk, offset);
1322 offset += chunk.byteLength;
1323 }
1324 return new TextDecoder().decode(prefix);
1325 });
1326
1327export const waitForDeploymentUrl = Effect.fn(function* (
1328 url: string | undefined,
1329 props: ComputeProps,
1330) {
1331 yield* validateComputeHealthCheck(props.healthCheck);
1332 if (props.healthCheck !== undefined && props.verifyUrl === false) {
1333 return yield* Effect.fail(
1334 new Error("healthCheck cannot be combined with verifyUrl: false."),
1335 );
1336 }
1337 if (props.verifyUrl === false) return;
1338 if (!url) {
1339 return yield* Effect.fail(
1340 new Error(
1341 props.healthCheck === undefined
1342 ? "Prisma Compute did not return a public URL for readiness verification."
1343 : "Prisma Compute did not return a public URL for the configured healthCheck.",
1344 ),
1345 );
1346 }
1347 const httpOption = yield* Effect.serviceOption(HttpClient.HttpClient);
1348 if (Option.isNone(httpOption)) {
1349 return props.healthCheck === undefined
1350 ? undefined
1351 : yield* Effect.fail(
1352 new Error(
1353 "Prisma Compute healthCheck requires an HTTP client service.",
1354 ),
1355 );
1356 }
1357
1358 const http = httpOption.value;
1359 const probeUrl =
1360 props.healthCheck === undefined
1361 ? url
1362 : new URL(props.healthCheck.path, url).toString();
1363 const acceptsStatus = (status: number) =>
1364 props.healthCheck === undefined ||
1365 (props.healthCheck.statusCodes === undefined
1366 ? status >= 200 && status <= 299
1367 : props.healthCheck.statusCodes.includes(status));
1368 const timeoutSeconds = props.urlReadinessTimeoutSeconds ?? 60;
1369 const intervalMs = props.pollIntervalMs ?? 1_000;
1370 if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
1371 return yield* Effect.fail(
1372 new Error("urlReadinessTimeoutSeconds must be a positive finite number."),
1373 );
1374 }
1375 if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
1376 return yield* Effect.fail(
1377 new Error("pollIntervalMs must be a positive finite number."),
1378 );
1379 }
1380 const timeoutMs = timeoutSeconds * 1_000;
1381 const startedAt = yield* Effect.sync(() => Date.now());
1382 let lastStatus: number | undefined;
1383 let lastBody = "";
1384
1385 while (true) {
1386 const beforeRequest = yield* Effect.sync(() => Date.now());
1387 const remainingBeforeRequest = timeoutMs - (beforeRequest - startedAt);
1388 if (remainingBeforeRequest <= 0) {
1389 return yield* Effect.fail(
1390 deploymentUrlTimeoutError(probeUrl, lastStatus, lastBody),
1391 );
1392 }
1393 const requestTimeoutMs = Math.min(5_000, remainingBeforeRequest);
1394 const response = yield* http.execute(HttpClientRequest.get(probeUrl)).pipe(
1395 Effect.provideService(FetchHttpClient.RequestInit, {
1396 redirect: "manual",
1397 }),
1398 Effect.timeoutOption(Duration.millis(requestTimeoutMs)),
1399 Effect.map(Option.getOrUndefined),
1400 Effect.catch(() => Effect.succeed(undefined)),
1401 );
1402 if (response) {
1403 lastStatus = response.status;
1404 // Without an application health check, any non-404 response proves that
1405 // edge routing is live. A configured health check must also match its
1406 // status contract. Do not consume non-404 bodies: application responses
1407 // may intentionally stream.
1408 if (response.status !== 404 && acceptsStatus(response.status)) {
1409 return;
1410 }
1411 const beforeBody = yield* Effect.sync(() => Date.now());
1412 const remainingBeforeBody = timeoutMs - (beforeBody - startedAt);
1413 if (remainingBeforeBody > 0) {
1414 const bodyPrefix = yield* readResponseBodyPrefix(response).pipe(
1415 Effect.timeoutOption(
1416 Duration.millis(Math.min(2_000, remainingBeforeBody)),
1417 ),
1418 Effect.catch(() => Effect.succeed(Option.none<string>())),
1419 );
1420 if (Option.isSome(bodyPrefix)) {
1421 lastBody = bodyPrefix.value;
1422 if (
1423 !isPrismaEdgeServiceNotFound(response.status, lastBody) &&
1424 acceptsStatus(response.status)
1425 ) {
1426 return;
1427 }
1428 }
1429 }
1430 }
1431
1432 const elapsed = yield* Effect.sync(() => Date.now() - startedAt);
1433 if (elapsed >= timeoutMs) {
1434 return yield* Effect.fail(
1435 deploymentUrlTimeoutError(probeUrl, lastStatus, lastBody),
1436 );
1437 }
1438
1439 yield* Effect.sleep(
1440 Duration.millis(Math.min(intervalMs, timeoutMs - elapsed)),
1441 );
1442 }
1443});
1444
1445const deploymentUrlTimeoutError = (
1446 url: string,
1447 lastStatus: number | undefined,
1448 lastBody: string,
1449) =>
1450 new Error(
1451 [
1452 `Timed out waiting for Prisma Compute URL '${url}' to become reachable.`,
1453 lastStatus
1454 ? `Last response: HTTP ${lastStatus}.`
1455 : "No HTTP response was received.",
1456 lastBody.includes("There is no service on this URL")
1457 ? "The Prisma edge returned: There is no service on this URL."
1458 : undefined,
1459 ]
1460 .filter((line): line is string => line !== undefined)
1461 .join(" "),
1462 );
1463
1464const isAutoBuild = (
1465 build: ComputeProps["build"],
1466): build is "auto" | ComputeAutoBuild =>
1467 build === "auto" ||
1468 (typeof build === "object" &&
1469 build !== null &&
1470 "type" in build &&
1471 build.type === "auto");
1472
1473const isStaticBuild = (
1474 build: ComputeProps["build"],
1475): build is ComputeStaticBuild =>
1476 typeof build === "object" &&
1477 build !== null &&
1478 "type" in build &&
1479 build.type === "static";
1480
1481const readPackageMain = Effect.fn(function* (directory: string) {
1482 const fs = yield* FileSystem.FileSystem;
1483 const path = yield* Path.Path;
1484 const text = yield* fs
1485 .readFileString(path.join(directory, "package.json"))
1486 .pipe(
1487 Effect.catchIf(
1488 (error) =>
1489 error._tag === "PlatformError" && error.reason._tag === "NotFound",
1490 () => Effect.succeed(undefined),
1491 ),
1492 );
1493 if (!text) return undefined;
1494 return yield* Effect.try({
1495 try: () => {
1496 const parsed = JSON.parse(text) as { main?: unknown };
1497 return typeof parsed.main === "string" ? parsed.main : undefined;
1498 },
1499 catch: (cause) =>
1500 new Error(`Failed to parse package.json in ${directory}: ${cause}`),
1501 });
1502});
1503
1504const writeBundleDirectory = Effect.fn(function* (bundle: Bundle.BundleOutput) {
1505 const fs = yield* FileSystem.FileSystem;
1506 const path = yield* Path.Path;
1507 if (bundle.files.length === 0) {
1508 return yield* Effect.fail(
1509 new Error("Effect-native Compute bundler produced no output files."),
1510 );
1511 }
1512 const normalizedFiles = yield* Effect.forEach(bundle.files, (file) =>
1513 normalizeBundleFilePath(file.path).pipe(
1514 Effect.map((normalizedPath) => ({ file, normalizedPath })),
1515 ),
1516 );
1517 const seen = new Set<string>();
1518 for (const { normalizedPath } of normalizedFiles) {
1519 if (seen.has(normalizedPath)) {
1520 return yield* Effect.fail(
1521 new Error(
1522 `Effect-native Compute bundler produced duplicate output path '${normalizedPath}'.`,
1523 ),
1524 );
1525 }
1526 seen.add(normalizedPath);
1527 }
1528 const directory = yield* fs.makeTempDirectory({
1529 prefix: "alchemy-prisma-compute-",
1530 });
1531
1532 return yield* Effect.gen(function* () {
1533 for (const { file, normalizedPath } of normalizedFiles) {
1534 const target = path.join(directory, normalizedPath);
1535 yield* fs.makeDirectory(path.dirname(target), { recursive: true });
1536 if (typeof file.content === "string") {
1537 yield* fs.writeFileString(target, file.content);
1538 } else {
1539 yield* fs.writeFile(target, file.content);
1540 }
1541 }
1542
1543 return {
1544 directory,
1545 entrypoint: normalizedFiles[0].normalizedPath,
1546 cleanup: fs
1547 .remove(directory, { recursive: true })
1548 .pipe(Effect.catch(() => Effect.void)),
1549 };
1550 }).pipe(
1551 Effect.onError(() =>
1552 fs.remove(directory, { recursive: true }).pipe(Effect.ignore),
1553 ),
1554 );
1555});
1556
1557const bundleEffectCompute = Effect.fn(function* (props: ComputeProps) {
1558 if (!props.main) {
1559 return yield* Effect.fail(
1560 new Error(
1561 "Effect-native Prisma Compute apps require `main`. Set `main: import.meta.filename`.",
1562 ),
1563 );
1564 }
1565
1566 const fs = yield* FileSystem.FileSystem;
1567 const stack = yield* Effect.serviceOption(Stack).pipe(
1568 Effect.map(
1569 Option.getOrElse(() => ({
1570 name: "alchemy",
1571 stage: "dev",
1572 bindings: {},
1573 resources: {},
1574 })),
1575 ),
1576 );
1577 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
1578 const realMain = yield* fs.realPath(props.main);
1579 const cwd = yield* findCwdForBundle(realMain);
1580 const handler = props.handler ?? "default";
1581 const defaultPort = props.port ?? 8080;
1582
1583 const importEntrypoint =
1584 handler === "default"
1585 ? "import entrypoint"
1586 : `import { ${handler} as entrypoint }`;
1587
1588 const bundle = yield* Bundle.build(
1589 {
1590 ...props.bundle?.input,
1591 input: realMain,
1592 cwd,
1593 platform: "node",
1594 // Prisma Compute runs the bundle on bun.
1595 resolve: {
1596 conditionNames: [...Bundle.BUN_CONDITION_NAMES],
1597 ...props.bundle?.input?.resolve,
1598 },
1599 plugins: [
1600 props.bundle?.input?.plugins,
1601 virtualEntryPlugin(
1602 (importPath) => `
1603import { bootstrap } from "alchemy/Runtime/Bootstrap/Prisma";
1604${importEntrypoint} from ${JSON.stringify(importPath)};
1605
1606await bootstrap(entrypoint, ${JSON.stringify({
1607 port: defaultPort,
1608 stack: { name: stack.name, stage: stack.stage },
1609 })});
1610`,
1611 ),
1612 ],
1613 checks: {
1614 unresolvedImport: false,
1615 ineffectiveDynamicImport: false,
1616 },
1617 },
1618 {
1619 ...props.bundle?.output,
1620 format: "esm",
1621 sourcemap: props.bundle?.output?.sourcemap ?? "hidden",
1622 minify: props.bundle?.output?.minify ?? true,
1623 entryFileNames: "index.js",
1624 },
1625 props.bundle?.extra,
1626 );
1627
1628 const artifact = yield* writeBundleDirectory(bundle);
1629 const file = yield* createComputeArchive({
1630 directory: artifact.directory,
1631 entrypoint: artifact.entrypoint,
1632 ignore: props.archiveIgnore,
1633 output: "file",
1634 }).pipe(Effect.ensuring(artifact.cleanup));
1635
1636 return {
1637 file,
1638 bundleHash: bundle.hash,
1639 };
1640});
1641
1642const resolveArtifact = Effect.fn(function* (props: ComputeProps) {
1643 const env = plainEnv(props.env);
1644 const envClass = props.envClass ?? "production";
1645 const defaultPort = props.port ?? 8080;
1646 if (isEffectNativeCompute(props)) {
1647 if (props.artifactPath !== undefined) {
1648 return yield* Effect.fail(
1649 new Error("Effect-native Prisma Compute apps cannot use artifactPath."),
1650 );
1651 }
1652 if (props.skipCodeUpload) {
1653 return yield* Effect.fail(
1654 new Error("Effect-native Prisma Compute apps cannot skip code upload."),
1655 );
1656 }
1657 if (props.build !== undefined) {
1658 return yield* Effect.fail(
1659 new Error("Effect-native Prisma Compute apps cannot use build."),
1660 );
1661 }
1662 const artifact = yield* bundleEffectCompute(props);
1663 return {
1664 file: artifact.file,
1665 hash: yield* sha256Object({
1666 bundle: artifact.bundleHash,
1667 env,
1668 envClass,
1669 port: defaultPort,
1670 }),
1671 port: defaultPort,
1672 };
1673 }
1674
1675 if (props.skipCodeUpload) {
1676 return {
1677 file: undefined,
1678 hash: yield* sha256Object({
1679 skipCodeUpload: true,
1680 env,
1681 envClass,
1682 port: defaultPort,
1683 }),
1684 port: defaultPort,
1685 };
1686 }
1687
1688 if (props.artifactPath !== undefined) {
1689 const file = yield* readUploadArtifact({
1690 artifactPath: props.artifactPath,
1691 output: "file",
1692 });
1693 return {
1694 file: file!,
1695 hash: yield* sha256Object({
1696 artifact: file!.sha256,
1697 env,
1698 envClass,
1699 port: defaultPort,
1700 }),
1701 port: defaultPort,
1702 };
1703 }
1704
1705 const path = yield* Path.Path;
1706 const appPath = path.resolve(props.path ?? ".");
1707 let directory = appPath;
1708 let entrypoint = props.entrypoint;
1709 let port = defaultPort;
1710
1711 if (isAutoBuild(props.build)) {
1712 const auto = props.build === "auto" ? undefined : props.build;
1713 const artifact = yield* runComputeAutoBuild({
1714 appPath,
1715 entrypoint,
1716 framework: auto?.framework,
1717 env: auto?.env,
1718 outputLimitBytes: auto?.outputLimitBytes,
1719 timeoutSeconds: auto?.timeoutSeconds,
1720 });
1721 const file = yield* createComputeArchive({
1722 directory: artifact.directory,
1723 entrypoint: artifact.entrypoint,
1724 ignore: props.archiveIgnore,
1725 ignorePrefix: artifact.archiveIgnorePrefix,
1726 requiredFiles: artifact.requiredFiles,
1727 output: "file",
1728 }).pipe(Effect.ensuring(artifact.cleanup));
1729 port = props.port ?? artifact.defaultPort ?? 8080;
1730 return {
1731 file,
1732 hash: yield* sha256Object({
1733 artifact: file.sha256,
1734 env,
1735 envClass,
1736 port,
1737 }),
1738 port,
1739 };
1740 }
1741
1742 if (isStaticBuild(props.build)) {
1743 const artifact = yield* runComputeStaticBuild({
1744 appPath,
1745 command: props.build.command,
1746 cwd: props.build.cwd,
1747 outdir: props.build.outdir,
1748 indexPage: props.build.indexPage,
1749 spa: props.build.spa,
1750 env: props.build.env,
1751 outputLimitBytes: props.build.outputLimitBytes,
1752 timeoutSeconds: props.build.timeoutSeconds,
1753 });
1754 const file = yield* createComputeArchive({
1755 directory: artifact.directory,
1756 entrypoint: artifact.entrypoint,
1757 ignore: props.archiveIgnore,
1758 ignorePrefix: artifact.archiveIgnorePrefix,
1759 requiredFiles: artifact.requiredFiles,
1760 output: "file",
1761 }).pipe(Effect.ensuring(artifact.cleanup));
1762 port = props.port ?? artifact.defaultPort ?? 8080;
1763 return {
1764 file,
1765 hash: yield* sha256Object({
1766 artifact: file.sha256,
1767 env,
1768 envClass,
1769 port,
1770 }),
1771 port,
1772 };
1773 }
1774
1775 if (props.build) {
1776 const cwd = props.build.cwd ? path.resolve(props.build.cwd) : appPath;
1777 yield* runBuildCommand({
1778 command: props.build.command,
1779 cwd,
1780 env: processEnv(props.build.env),
1781 outputLimitBytes: props.build.outputLimitBytes,
1782 timeoutSeconds: props.build.timeoutSeconds,
1783 });
1784 directory = path.resolve(cwd, props.build.outdir);
1785 entrypoint = props.build.entrypoint ?? entrypoint;
1786 }
1787
1788 entrypoint ??= yield* readPackageMain(directory);
1789 if (!entrypoint) {
1790 return yield* Effect.fail(
1791 new Error(
1792 "Prisma Compute app entrypoint is required. Set `entrypoint` or package.json `main`.",
1793 ),
1794 );
1795 }
1796
1797 const normalizedEntrypoint = yield* normalizeEntrypoint(entrypoint);
1798 const file = yield* createComputeArchive({
1799 directory,
1800 entrypoint: normalizedEntrypoint,
1801 ignore: props.archiveIgnore,
1802 output: "file",
1803 });
1804 return {
1805 file,
1806 hash: yield* sha256Object({
1807 artifact: file.sha256,
1808 env,
1809 envClass,
1810 port,
1811 }),
1812 port,
1813 };
1814});
1815
1816const findExistingApp = Effect.fn(function* (
1817 output: Compute["Attributes"] | undefined,
1818) {
1819 const appId =
1820 output?.appId && !isPrismaDevId(output.appId) ? output.appId : undefined;
1821 return appId
1822 ? yield* getService({ serviceId: appId }).pipe(
1823 Effect.map((response) => response.data),
1824 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1825 )
1826 : undefined;
1827});
1828
1829const ensureApp = Effect.fn(function* (
1830 projectId: string,
1831 props: ComputeProps & { appName: string },
1832 output: Compute["Attributes"] | undefined,
1833 observedApp?: ObservedApp,
1834) {
1835 let app: ObservedApp | undefined =
1836 observedApp ?? (yield* findExistingApp(output));
1837 const branch = yield* desiredComputeBranchId(projectId, props);
1838 if (!branch.resolved) {
1839 return yield* Effect.fail(
1840 new Error(
1841 props.branchGitName === undefined
1842 ? `Prisma project '${projectId}' has no default branch to attach Compute App '${props.appName}'. Create or promote a default branch, or specify branchId/branchGitName.`
1843 : `Prisma project '${projectId}' has no branch named '${props.branchGitName}' to attach Compute App '${props.appName}'.`,
1844 ),
1845 );
1846 }
1847
1848 let createdApp = false;
1849 if (!app) {
1850 const result = yield* createApp(projectId, props, branch.id).pipe(
1851 Effect.map((app) => ({ app, created: true })),
1852 Effect.catchTag("Conflict", (conflict) =>
1853 findApp(projectId, props.appName, props).pipe(
1854 Effect.flatMap((app) =>
1855 app && output?.appId !== undefined && app.id === output.appId
1856 ? Effect.succeed({ app, created: false })
1857 : Effect.fail(
1858 new Error(
1859 `Prisma App '${props.appName}' already exists on the requested branch but is not owned by this Compute resource. Import it with explicit adoption or choose a different App name.`,
1860 { cause: conflict },
1861 ),
1862 ),
1863 ),
1864 ),
1865 ),
1866 );
1867 app = result.app;
1868 createdApp = result.created;
1869 }
1870
1871 yield* ensureAppImmutableIdentity(
1872 app,
1873 projectId,
1874 props.regionId ?? output?.regionId ?? app.region.id,
1875 );
1876 if (app.name !== props.appName || app.branchId !== branch.id) {
1877 app = yield* updateService({
1878 serviceId: app.id,
1879 displayName: props.appName,
1880 branchId: branch.id,
1881 }).pipe(Effect.map((response) => response.data));
1882 }
1883
1884 return { app, created: createdApp };
1885});
1886
1887const ensureSkipCodeUploadCanFork = (
1888 props: ComputeProps,
1889 app: ObservedApp | undefined,
1890) =>
1891 Effect.gen(function* () {
1892 if (!(props.skipCodeUpload ?? false)) return;
1893 if (app?.latestDeploymentId) return;
1894 return yield* Effect.fail(
1895 new Error(
1896 "skipCodeUpload requires an existing Prisma deployment to fork from. Upload and start a deployment first, or remove skipCodeUpload.",
1897 ),
1898 );
1899 });
1900
1901const findEnvironmentVariable = (
1902 projectId: string,
1903 cls: "production" | "preview",
1904 key: string,
1905 branchId?: string | null,
1906) =>
1907 Effect.gen(function* () {
1908 // Distilled emits the cursor-paginated list operations as plain ops, so
1909 // callers walk `pagination` themselves (see `src/Neon/Project.ts`).
1910 const variables: GetEnvironmentVariablesResponse["data"][number][] = [];
1911 let cursor: string | undefined;
1912 while (true) {
1913 const page = yield* getEnvironmentVariables({
1914 projectId,
1915 class: cls,
1916 key,
1917 limit: 100,
1918 ...(branchId ? { branchId } : {}),
1919 ...(cursor === undefined ? {} : { cursor }),
1920 });
1921 variables.push(...page.data);
1922 const nextCursor = page.pagination.nextCursor;
1923 if (!page.pagination.hasMore) break;
1924 if (nextCursor === null) {
1925 return yield* Effect.fail(
1926 new PrismaPaginationError({
1927 message:
1928 "Invalid Prisma Management API pagination response from getEnvironmentVariables: hasMore was true without a non-empty nextCursor",
1929 }),
1930 );
1931 }
1932 cursor = nextCursor;
1933 }
1934 return variables.find(
1935 (variable) => variable.branchId === (branchId ?? null),
1936 );
1937 });
1938
1939const systemManagedEnvironmentVariableError = (key: string) =>
1940 new Error(
1941 `Prisma environment variable '${key}' is managed by Prisma and cannot be managed by Alchemy.`,
1942 );
1943
1944const ensureUserManagedEnvironmentVariable = (
1945 variable: ObservedEnvironmentVariable,
1946) =>
1947 Effect.gen(function* () {
1948 if (variable.isManagedBySystem) {
1949 return yield* Effect.fail(
1950 systemManagedEnvironmentVariableError(variable.key),
1951 );
1952 }
1953 });
1954
1955const environmentVariableOwnershipError = (key: string, variableId: string) =>
1956 new Error(
1957 `Prisma environment variable '${key}' (${variableId}) already exists in this branch/class scope but is not owned by this Compute resource. It may be foreign or left by an interrupted prior Compute deploy; the Management API exposes no ownership marker or plaintext value that would make takeover safe. Use one standalone Prisma.EnvironmentVariable resource as the independently manageable owner, choose a different key, explicitly adopt that resource, or remove the existing variable before deploying.`,
1958 );
1959
1960interface ComputeEnvironmentScope {
1961 class: "production" | "preview";
1962 branchId: string | null;
1963}
1964
1965const environmentScope = (
1966 cls: "production" | "preview",
1967 branchId?: string | null,
1968): ComputeEnvironmentScope => ({
1969 class: cls,
1970 branchId: branchId ?? null,
1971});
1972
1973const sameEnvironmentScope = (
1974 left: ComputeEnvironmentScope,
1975 right: ComputeEnvironmentScope,
1976) => left.class === right.class && left.branchId === right.branchId;
1977
1978const resolveComputeEnvironmentScope = Effect.fn(function* (
1979 app: ObservedApp,
1980 props: ComputeProps,
1981) {
1982 if (!app.branchId) {
1983 return yield* Effect.fail(
1984 new Error(
1985 "Prisma.Compute requires an attached branch because deployment creation resolves environment variables from the App branch.",
1986 ),
1987 );
1988 }
1989
1990 const branch = yield* getBranch({
1991 branchId: app.branchId,
1992 }).pipe(Effect.map((response) => response.data));
1993 const inferredClass = branch.role;
1994 if (props.envClass !== undefined && props.envClass !== inferredClass) {
1995 return yield* Effect.fail(
1996 new Error(
1997 `Prisma.Compute envClass '${props.envClass}' does not match attached branch role '${inferredClass}'. Omit envClass to let Alchemy infer the correct environment variable scope.`,
1998 ),
1999 );
2000 }
2001
2002 return environmentScope(
2003 inferredClass,
2004 inferredClass === "preview" ? app.branchId : null,
2005 );
2006});
2007
2008interface ComputeEnvironmentPlan {
2009 key: string;
2010 value: string | null;
2011 variable: ObservedEnvironmentVariable | undefined;
2012}
2013
2014const rollbackCreatedEnvironmentVariables = Effect.fn(function* (
2015 createdIds: ReadonlyArray<{ key: string; id: string }>,
2016 originalError: unknown,
2017) {
2018 if (createdIds.length === 0) {
2019 return yield* Effect.fail(originalError);
2020 }
2021 const cleanupErrors: unknown[] = [];
2022 for (const created of [...createdIds].reverse()) {
2023 const result = yield* Effect.result(
2024 deleteEnvironmentVariable({ envVarId: created.id }).pipe(
2025 Effect.catchTag("NotFound", () => Effect.void),
2026 ),
2027 );
2028 if (Result.isFailure(result)) {
2029 cleanupErrors.push(result.failure);
2030 }
2031 }
2032 if (cleanupErrors.length > 0) {
2033 return yield* Effect.fail(
2034 new AggregateError(
2035 [originalError, ...cleanupErrors],
2036 `Failed to roll back newly created Prisma environment variables after reconcile failed. Manual cleanup: ${createdIds.map(({ id }) => `DELETE /v1/environment-variables/${id}`).join(", ")}.`,
2037 ),
2038 );
2039 }
2040 return yield* Effect.fail(originalError);
2041});
2042
2043const syncComputeEnvironmentInternal = Effect.fn(function* (
2044 projectId: string,
2045 cls: "production" | "preview",
2046 env: Record<
2047 string,
2048 string | Redacted.Redacted<string> | null | undefined
2049 > = {},
2050 branchId?: string | null,
2051 ownedIds: Readonly<Record<string, string>> = {},
2052) {
2053 const synced: string[] = [];
2054 const deleted: string[] = [];
2055 const nextOwnedIds: Record<string, string> = {};
2056 const createdIds: Array<{ key: string; id: string }> = [];
2057 const plans: ComputeEnvironmentPlan[] = [];
2058
2059 // Observe and validate the entire scope before the first write. In
2060 // particular, a foreign/system-owned key discovered late in the input must
2061 // not leave earlier keys partially created.
2062 for (const [key, value] of Object.entries(plainEnv(env))) {
2063 if (value === null) {
2064 yield* validateComputeEnvironmentKey(key);
2065 } else {
2066 yield* validateComputeEnvironmentWrite(key, value);
2067 }
2068 const variable = yield* findEnvironmentVariable(
2069 projectId,
2070 cls,
2071 key,
2072 branchId,
2073 );
2074 if (variable) {
2075 yield* ensureUserManagedEnvironmentVariable(variable);
2076 if (ownedIds[key] !== variable.id) {
2077 return yield* Effect.fail(
2078 environmentVariableOwnershipError(key, variable.id),
2079 );
2080 }
2081 }
2082 plans.push({ key, value, variable });
2083 }
2084
2085 const apply = Effect.gen(function* () {
2086 for (const { key, value, variable } of plans) {
2087 if (value === null) continue;
2088 if (variable) {
2089 yield* updateEnvironmentVariable({
2090 envVarId: variable.id,
2091 value,
2092 });
2093 nextOwnedIds[key] = variable.id;
2094 } else {
2095 const created = yield* createEnvironmentVariable({
2096 projectId,
2097 ...(branchId ? { branchId } : {}),
2098 class: cls,
2099 key,
2100 value,
2101 }).pipe(
2102 // A replayed create would make a second variable; the retry policy
2103 // cannot see the request, so opt out explicitly.
2104 Retry.none,
2105 Effect.map((response) => response.data),
2106 );
2107 createdIds.push({ key, id: created.id });
2108 nextOwnedIds[key] = created.id;
2109 }
2110 synced.push(key);
2111 }
2112 // Apply explicit deletions only after all ownership checks and upserts.
2113 for (const { key, value, variable } of plans) {
2114 if (value !== null || !variable) continue;
2115 yield* deleteEnvironmentVariable({
2116 envVarId: variable.id,
2117 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
2118 deleted.push(key);
2119 }
2120 return { synced, deleted, ownedIds: nextOwnedIds, createdIds };
2121 });
2122
2123 return yield* apply.pipe(
2124 Effect.catch((error) =>
2125 rollbackCreatedEnvironmentVariables(createdIds, error),
2126 ),
2127 );
2128});
2129
2130export const syncComputeEnvironment = Effect.fn(function* (
2131 projectId: string,
2132 cls: "production" | "preview",
2133 env: Record<
2134 string,
2135 string | Redacted.Redacted<string> | null | undefined
2136 > = {},
2137 branchId?: string | null,
2138 ownedIds: Readonly<Record<string, string>> = {},
2139) {
2140 const result = yield* syncComputeEnvironmentInternal(
2141 projectId,
2142 cls,
2143 env,
2144 branchId,
2145 ownedIds,
2146 );
2147 return {
2148 synced: result.synced,
2149 deleted: result.deleted,
2150 ownedIds: result.ownedIds,
2151 };
2152});
2153
2154const destroyComputeEnvironment = Effect.fn(function* (
2155 projectId: string,
2156 scope: ComputeEnvironmentScope,
2157 ownedIds: Readonly<Record<string, string>> = {},
2158) {
2159 const deleted: string[] = [];
2160 for (const [key, ownedId] of Object.entries(ownedIds)) {
2161 const variable = yield* findEnvironmentVariable(
2162 projectId,
2163 scope.class,
2164 key,
2165 scope.branchId,
2166 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
2167 if (!variable) continue;
2168 if (variable.id !== ownedId || variable.isManagedBySystem) continue;
2169 yield* deleteEnvironmentVariable({
2170 envVarId: variable.id,
2171 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
2172 deleted.push(key);
2173 }
2174 return { deleted };
2175});
2176
2177const cleanupRemovedComputeEnvironment = Effect.fn(function* (
2178 projectId: string,
2179 oldScope: ComputeEnvironmentScope,
2180 oldOwnedIds: Readonly<Record<string, string>>,
2181 newScope: ComputeEnvironmentScope,
2182 newEnv:
2183 | Record<string, string | Redacted.Redacted<string> | null | undefined>
2184 | undefined,
2185) {
2186 const newValues = plainEnv(newEnv);
2187 const deleted: string[] = [];
2188 for (const [key, ownedId] of Object.entries(oldOwnedIds)) {
2189 if (sameEnvironmentScope(oldScope, newScope) && key in newValues) continue;
2190 const variable = yield* findEnvironmentVariable(
2191 projectId,
2192 oldScope.class,
2193 key,
2194 oldScope.branchId,
2195 );
2196 if (!variable) continue;
2197 if (variable.id !== ownedId || variable.isManagedBySystem) continue;
2198 yield* deleteEnvironmentVariable({
2199 envVarId: variable.id,
2200 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
2201 deleted.push(key);
2202 }
2203 return { deleted };
2204});
2205
2206const startDev = Effect.fn(function* (id: string, props: ComputeProps) {
2207 const path = yield* Path.Path;
2208 const dev = props.dev;
2209 const processKey = `dev:${id}`;
2210 const localUrl =
2211 dev?.url ??
2212 ((dev?.port ?? props.port)
2213 ? `http://localhost:${dev?.port ?? props.port}`
2214 : undefined);
2215 if (!dev?.command) {
2216 return localUrl;
2217 }
2218
2219 yield* stopTrackedDevProcess(processKey);
2220
2221 const cwd = dev.cwd ? path.resolve(dev.cwd) : path.resolve(props.path ?? ".");
2222 const env = {
2223 NODE_ENV: "development",
2224 ...processEnv(props.env),
2225 ...processEnv(dev.env),
2226 ...((dev.port ?? props.port)
2227 ? { PORT: String(dev.port ?? props.port) }
2228 : {}),
2229 };
2230 const handle = yield* ChildProcess.make(dev.command, [], {
2231 shell: true,
2232 cwd,
2233 env,
2234 extendEnv: true,
2235 stdin: "inherit",
2236 stdout: "inherit",
2237 stderr: "inherit",
2238 detached: false,
2239 });
2240 devProcesses.set(processKey, handle);
2241 return localUrl;
2242});
2243
2244const activeBindingEnv = (
2245 bindings: ResourceBinding<Compute["Binding"]>[],
2246): Record<string, string | Redacted.Redacted<string> | null | undefined> =>
2247 bindings
2248 .filter(
2249 (binding: ResourceBinding<Compute["Binding"]> & { action?: string }) =>
2250 binding.action !== "delete",
2251 )
2252 .map((binding) => binding.data?.env)
2253 .reduce<
2254 Record<string, string | Redacted.Redacted<string> | null | undefined>
2255 >(
2256 (acc, env) => (env ? { ...acc, ...env } : acc),
2257 {} as Record<
2258 string,
2259 string | Redacted.Redacted<string> | null | undefined
2260 >,
2261 );
2262
2263const ProviderLive = () =>
2264 Provider.effect(
2265 Compute,
2266 Effect.gen(function* () {
2267 return {
2268 stables: ["appId"],
2269 // Compute is a composite over App + Deployment. AppProvider owns nuke
2270 // enumeration to avoid deleting the same canonical App twice.
2271 list: () => Effect.succeed([]),
2272 diff: Effect.fn(function* ({ olds, news, output }) {
2273 if (!isInputObject(news)) return undefined;
2274 if (output?.local || isPrismaDevId(output?.appId)) {
2275 return { action: "update" } as const;
2276 }
2277 const oldProjectId =
2278 output?.projectId ?? unresolvedProjectIdOf(olds.project);
2279 const newProjectId = isResolved(news.project)
2280 ? unresolvedProjectIdOf(news.project)
2281 : undefined;
2282 if (concreteIdsChanged(oldProjectId, newProjectId)) {
2283 // A different project is a distinct API uniqueness scope, so the
2284 // old and replacement Apps can safely coexist.
2285 return { action: "replace" } as const;
2286 }
2287 if (isResolved(news.regionId) && news.regionId !== undefined) {
2288 const currentRegionId = output?.regionId ?? olds.regionId;
2289 if (
2290 currentRegionId !== undefined &&
2291 news.regionId !== currentRegionId
2292 ) {
2293 return yield* Effect.fail(
2294 new Error(
2295 `Prisma Compute App region is immutable and cannot be changed atomically without deleting the live App first. Deploy a second Compute App under a different appName in the target region, cut traffic over, then remove the original.`,
2296 ),
2297 );
2298 }
2299 }
2300 // Source files/build output can change with no prop change, so
2301 // reconcile must rerun and decide from the computed artifact hash.
2302 return { action: "update" } as const;
2303 }),
2304 read: Effect.fn(function* ({ id, output, olds }) {
2305 if (output?.local) return output;
2306 const appId =
2307 output?.appId && !isPrismaDevId(output.appId)
2308 ? output.appId
2309 : undefined;
2310 const app = appId
2311 ? yield* getService({ serviceId: appId }).pipe(
2312 Effect.map((response) => response.data),
2313 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
2314 )
2315 : yield* Effect.gen(function* () {
2316 const projectId = unresolvedProjectIdOf(olds.project);
2317 return projectId
2318 ? yield* findApp(
2319 projectId,
2320 yield* createAppName(id, olds.appName),
2321 olds,
2322 )
2323 : undefined;
2324 });
2325 if (!app) return undefined;
2326 const readDeployment = (id: string) =>
2327 observeDeployment(id).pipe(
2328 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
2329 );
2330 const outputDeployment = output?.deploymentId
2331 ? yield* readDeployment(output.deploymentId)
2332 : undefined;
2333 if (outputDeployment) {
2334 yield* ensureDeploymentMembership(
2335 app.id,
2336 outputDeployment,
2337 app.latestDeploymentId,
2338 );
2339 }
2340 const latestDeployment =
2341 !outputDeployment && app.latestDeploymentId
2342 ? yield* readDeployment(app.latestDeploymentId)
2343 : undefined;
2344 const deployment = outputDeployment ?? latestDeployment;
2345 const deploymentUrl = toDeploymentUrl(
2346 deployment?.previewDomain ?? undefined,
2347 );
2348 const appUrl = toDeploymentUrl(app.appEndpointDomain);
2349 const promoted =
2350 app.latestDeploymentId !== null &&
2351 deployment?.id === app.latestDeploymentId;
2352 const attrs: Compute["Attributes"] = {
2353 appId: app.id,
2354 deploymentId: deployment?.id,
2355 projectId: app.projectId ?? output?.projectId,
2356 appName: app.name,
2357 regionId: app.region.id,
2358 deploymentEndpointDomain: deployment?.previewDomain ?? undefined,
2359 deploymentUrl,
2360 appEndpointDomain:
2361 app.appEndpointDomain ?? output?.appEndpointDomain,
2362 url: promoted ? (appUrl ?? deploymentUrl) : deploymentUrl,
2363 promoted,
2364 previousDeploymentId: output?.previousDeploymentId,
2365 previousDeploymentAction: output?.previousDeploymentAction,
2366 readinessStatus: output?.readinessStatus,
2367 pendingDeploymentCleanup: output?.pendingDeploymentCleanup,
2368 environmentKeys: output?.environmentKeys,
2369 environmentVariableIds: output?.environmentVariableIds,
2370 environmentClass: output?.environmentClass ?? olds.envClass,
2371 environmentBranchId: output?.environmentBranchId,
2372 // A latest-deployment fallback is unrelated to a missing saved
2373 // deployment. Never transfer its artifact identity to other code.
2374 artifactHash: outputDeployment ? output?.artifactHash : undefined,
2375 local: false,
2376 };
2377 return appId ? attrs : Unowned(attrs);
2378 }),
2379 reconcile: Effect.fn(function* ({ id, news, olds, output, bindings }) {
2380 const bindingEnv = activeBindingEnv(bindings);
2381 const effectiveNews = {
2382 ...news,
2383 appName: yield* createAppName(id, news.appName),
2384 env: {
2385 ...bindingEnv,
2386 ...news.env,
2387 },
2388 };
2389 yield* validateComputeProps(effectiveNews);
2390 const projectId = yield* resolveProjectId(effectiveNews.project);
2391 const artifact = yield* resolveArtifact(effectiveNews);
2392 const cleanupArtifact = artifact.file?.cleanup ?? Effect.void;
2393 const releaseArtifactOnFailure = <A, E, R>(
2394 effect: Effect.Effect<A, E, R>,
2395 ) =>
2396 effect.pipe(
2397 Effect.onExit((exit) =>
2398 Exit.isSuccess(exit) ? Effect.void : cleanupArtifact,
2399 ),
2400 );
2401 const observedApp = effectiveNews.skipCodeUpload
2402 ? yield* releaseArtifactOnFailure(
2403 findExistingApp(output).pipe(
2404 Effect.retry({
2405 while: isAppProvisioningNotFound,
2406 schedule: projectConsistencySchedule,
2407 }),
2408 ),
2409 )
2410 : undefined;
2411 yield* releaseArtifactOnFailure(
2412 ensureSkipCodeUploadCanFork(effectiveNews, observedApp),
2413 );
2414 const ensuredApp = yield* releaseArtifactOnFailure(
2415 ensureApp(projectId, effectiveNews, output, observedApp).pipe(
2416 Effect.retry({
2417 while: isAppProvisioningNotFound,
2418 schedule: projectConsistencySchedule,
2419 }),
2420 ),
2421 );
2422 let app = ensuredApp.app;
2423 let preserveCreatedAppOnFailure = false;
2424 const cleanupCreatedAppOnFailure = (error: unknown) =>
2425 ensuredApp.created && !preserveCreatedAppOnFailure
2426 ? destroyApp(app.id).pipe(
2427 Effect.catch((cleanupError) =>
2428 Effect.fail(
2429 aggregateCleanupFailure(
2430 "App",
2431 app.id,
2432 `/v1/services/${app.id}`,
2433 error,
2434 cleanupError,
2435 ),
2436 ),
2437 ),
2438 Effect.andThen(() => Effect.fail(error)),
2439 )
2440 : Effect.fail(error);
2441
2442 let createdEnvironmentVariableIds: Array<{
2443 key: string;
2444 id: string;
2445 }> = [];
2446 let deploymentConverged = false;
2447
2448 return yield* Effect.gen(function* () {
2449 const currentEnvironmentScope =
2450 yield* resolveComputeEnvironmentScope(app, effectiveNews);
2451 const deploymentHash = yield* sha256Object({
2452 artifact: artifact.hash,
2453 branchId: app.branchId,
2454 environmentClass: currentEnvironmentScope.class,
2455 environmentBranchId: currentEnvironmentScope.branchId,
2456 });
2457 const outputArtifactHash = persistedArtifactHashValue(
2458 output?.artifactHash as
2459 | Redacted.Redacted<string>
2460 | string
2461 | undefined,
2462 );
2463 const persistedDeployment = output?.deploymentId
2464 ? yield* observeDeployment(output.deploymentId).pipe(
2465 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
2466 )
2467 : undefined;
2468 const terminalFailedDeploymentId =
2469 persistedDeployment?.status === "failed"
2470 ? persistedDeployment.id
2471 : undefined;
2472 const promotionRequested = !(effectiveNews.skipPromote ?? false);
2473 let persistedPendingCleanup = output?.pendingDeploymentCleanup;
2474 const cleanupPreviousDeployment = Effect.fn(function* (
2475 deploymentId: string,
2476 action: "stop" | "destroy",
2477 ) {
2478 const observed = yield* observeDeployment(deploymentId).pipe(
2479 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
2480 );
2481 if (!observed) {
2482 return (action === "destroy" ? "destroyed" : "stopped") as
2483 | "destroyed"
2484 | "stopped";
2485 }
2486 yield* ensureDeploymentMembership(
2487 app.id,
2488 observed,
2489 app.latestDeploymentId,
2490 );
2491 if (action === "destroy") {
2492 yield* destroyDeployment(deploymentId, effectiveNews);
2493 return "destroyed" as const;
2494 }
2495 yield* stopDeploymentIdempotent(deploymentId).pipe(
2496 Effect.catchTag("NotFound", () => Effect.void),
2497 );
2498 yield* waitForDeploymentStatus(
2499 deploymentId,
2500 "stopped",
2501 effectiveNews,
2502 ).pipe(
2503 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
2504 );
2505 return "stopped" as const;
2506 });
2507
2508 if (
2509 terminalFailedDeploymentId !== undefined &&
2510 promotionRequested &&
2511 output?.promoted === true &&
2512 app.latestDeploymentId !== null &&
2513 app.latestDeploymentId !== terminalFailedDeploymentId
2514 ) {
2515 return yield* Effect.fail(
2516 new AggregateError(
2517 [],
2518 `Prisma Compute state preserves terminal failed deployment '${terminalFailedDeploymentId}', but App '${app.id}' reports deployment '${app.latestDeploymentId}' as latest. Alchemy cannot prove that the live deployment is the interrupted replacement, and the persisted failed deployment is not a safe rollback target. No new deployment was created and neither deployment was deleted.`,
2519 ),
2520 );
2521 }
2522
2523 // Apply persists the previous stable Attributes before reconcile.
2524 // If a prior attempt promoted a new generation, failed its stable
2525 // health check, and could not roll back, that durable output still
2526 // identifies the deployment we must restore while the App points
2527 // at the displaced unhealthy generation. Recover that transition
2528 // before changing env or creating another deployment, otherwise a
2529 // retry would use the unhealthy generation as its rollback target.
2530 if (
2531 promotionRequested &&
2532 output?.promoted === true &&
2533 output.deploymentId !== undefined &&
2534 terminalFailedDeploymentId === undefined &&
2535 outputArtifactHash !== deploymentHash &&
2536 app.latestDeploymentId !== null &&
2537 app.latestDeploymentId !== output.deploymentId
2538 ) {
2539 const persistedDeploymentId = output.deploymentId;
2540 const displacedDeploymentId = app.latestDeploymentId;
2541 const rollback = yield* createServiceRollback({
2542 serviceId: app.id,
2543 deploymentId: persistedDeploymentId,
2544 }).pipe(
2545 Effect.map((response) => response.data),
2546 Effect.result,
2547 );
2548 const observedAfterRollback = yield* waitForAppDeploymentTarget(
2549 app.id,
2550 persistedDeploymentId,
2551 effectiveNews,
2552 ).pipe(Effect.result);
2553
2554 if (Result.isFailure(observedAfterRollback)) {
2555 return yield* Effect.fail(
2556 new AggregateError(
2557 [
2558 ...(Result.isFailure(rollback) ? [rollback.failure] : []),
2559 ...(Result.isFailure(observedAfterRollback)
2560 ? [observedAfterRollback.failure]
2561 : []),
2562 ],
2563 `Prisma App '${app.id}' has live deployment '${displacedDeploymentId}', while Alchemy state preserves deployment '${persistedDeploymentId}' as the prior promoted generation. Recovery via POST /v1/services/${app.id}/rollback did not converge, so no environment variables or new deployment were changed and neither deployment was deleted.`,
2564 ),
2565 );
2566 }
2567
2568 app = observedAfterRollback.success;
2569 yield* destroyDeployment(
2570 displacedDeploymentId,
2571 effectiveNews,
2572 ).pipe(
2573 Effect.catch((cleanupError) =>
2574 Effect.fail(
2575 new AggregateError(
2576 [
2577 ...(Result.isFailure(rollback)
2578 ? [rollback.failure]
2579 : []),
2580 cleanupError,
2581 ],
2582 `Prisma App '${app.id}' was restored to deployment '${persistedDeploymentId}', but displaced deployment '${displacedDeploymentId}' could not be deleted. No new deployment was created; retry cleanup with DELETE /v1/deployments/${displacedDeploymentId}.`,
2583 ),
2584 ),
2585 ),
2586 );
2587 }
2588
2589 if (
2590 output?.readinessStatus === "pending" &&
2591 outputArtifactHash !== deploymentHash
2592 ) {
2593 return yield* Effect.fail(
2594 new Error(
2595 `Prisma Compute deployment '${output.deploymentId ?? "unknown"}' still has pending stable-endpoint readiness. Reconcile the existing generation before changing code or environment inputs.`,
2596 ),
2597 );
2598 }
2599 // Keep one cleanup candidate. A non-live older candidate is
2600 // drained before any env/deploy mutation; a candidate that is
2601 // still live is derived from app.latestDeploymentId below.
2602 if (
2603 persistedPendingCleanup !== undefined &&
2604 output?.readinessStatus !== "pending" &&
2605 persistedPendingCleanup.deploymentId !== app.latestDeploymentId
2606 ) {
2607 yield* cleanupPreviousDeployment(
2608 persistedPendingCleanup.deploymentId,
2609 persistedPendingCleanup.action,
2610 );
2611 persistedPendingCleanup = undefined;
2612 } else if (
2613 persistedPendingCleanup?.deploymentId === app.latestDeploymentId
2614 ) {
2615 persistedPendingCleanup = undefined;
2616 }
2617 const previousEnvironmentScope = environmentScope(
2618 olds?.envClass ?? output?.environmentClass ?? "production",
2619 output?.environmentBranchId,
2620 );
2621 const previousEnvironmentVariableIds =
2622 output?.environmentVariableIds ?? {};
2623 const environmentResult = yield* syncComputeEnvironmentInternal(
2624 projectId,
2625 currentEnvironmentScope.class,
2626 effectiveNews.env,
2627 currentEnvironmentScope.branchId,
2628 sameEnvironmentScope(
2629 previousEnvironmentScope,
2630 currentEnvironmentScope,
2631 )
2632 ? previousEnvironmentVariableIds
2633 : {},
2634 );
2635 createdEnvironmentVariableIds = environmentResult.createdIds;
2636
2637 // Deployment creation snapshots the App branch environment. Remove
2638 // formerly-owned keys before creating it so deleted keys cannot be
2639 // captured into the new runtime. Any failure here happens before
2640 // deployment creation and rolls back newly-created variables.
2641 yield* cleanupRemovedComputeEnvironment(
2642 projectId,
2643 previousEnvironmentScope,
2644 previousEnvironmentVariableIds,
2645 currentEnvironmentScope,
2646 effectiveNews.env,
2647 );
2648
2649 let deployment: ObservedDeployment | undefined =
2650 outputArtifactHash === deploymentHash
2651 ? persistedDeployment
2652 : undefined;
2653 // A failed deployment is terminal and cannot be repaired by
2654 // replaying start/promotion. Preserve it as the cleanup target,
2655 // but create a fresh generation and do not delete the failed one
2656 // until the replacement has passed preview + stable readiness and
2657 // promotion is observed.
2658 if (
2659 persistedDeployment !== undefined &&
2660 (deployment !== undefined ||
2661 terminalFailedDeploymentId !== undefined)
2662 ) {
2663 yield* ensureDeploymentMembership(
2664 app.id,
2665 persistedDeployment,
2666 app.latestDeploymentId,
2667 );
2668 }
2669 if (terminalFailedDeploymentId !== undefined) {
2670 deployment = undefined;
2671 }
2672 let createdDeploymentId: string | undefined;
2673 const cleanupCreatedDeploymentOnFailure = (
2674 failedDeploymentId: string,
2675 error: unknown,
2676 ) =>
2677 createdDeploymentId === failedDeploymentId
2678 ? destroyDeployment(failedDeploymentId, effectiveNews).pipe(
2679 Effect.catch((cleanupError) =>
2680 Effect.fail(
2681 aggregateCleanupFailure(
2682 "deployment",
2683 failedDeploymentId,
2684 `/v1/deployments/${failedDeploymentId}`,
2685 error,
2686 cleanupError,
2687 ),
2688 ),
2689 ),
2690 Effect.andThen(() => Effect.fail(error)),
2691 )
2692 : Effect.fail(error);
2693
2694 if (!deployment) {
2695 const created = yield* createServiceDeployment({
2696 serviceId: app.id,
2697 portMapping: { http: artifact.port },
2698 ...(effectiveNews.skipCodeUpload === undefined
2699 ? {}
2700 : { skipCodeUpload: effectiveNews.skipCodeUpload }),
2701 }).pipe(
2702 // A replayed create would make a second deployment; the retry
2703 // policy cannot see the request, so opt out explicitly.
2704 Retry.none,
2705 Effect.map((response) => response.data),
2706 );
2707 createdDeploymentId = created.id;
2708 if (artifact.file !== undefined && !created.uploadUrl) {
2709 return yield* cleanupCreatedDeploymentOnFailure(
2710 created.id,
2711 new Error(
2712 "Prisma deployment creation did not return an upload URL.",
2713 ),
2714 );
2715 }
2716 if (created.uploadUrl && artifact.file !== undefined) {
2717 yield* uploadArtifact(
2718 created.uploadUrl,
2719 artifact.file,
2720 "application/gzip",
2721 ).pipe(
2722 Effect.catch((error) =>
2723 cleanupCreatedDeploymentOnFailure(created.id, error),
2724 ),
2725 );
2726 }
2727 deployment = yield* observeDeployment(created.id).pipe(
2728 Effect.catchTag("NotFound", () =>
2729 Effect.succeed({
2730 id: created.id,
2731 foundryVersionId: created.foundryVersionId,
2732 status: "new",
2733 previewDomain: null,
2734 createdAt: undefined,
2735 }),
2736 ),
2737 Effect.catch((error) =>
2738 cleanupCreatedDeploymentOnFailure(created.id, error),
2739 ),
2740 );
2741 }
2742 if (!deployment) {
2743 return yield* Effect.fail(
2744 new Error(
2745 "Prisma deployment could not be resolved after creation.",
2746 ),
2747 );
2748 }
2749
2750 const previousDeploymentId =
2751 terminalFailedDeploymentId ??
2752 persistedPendingCleanup?.deploymentId ??
2753 (app.latestDeploymentId !== null &&
2754 app.latestDeploymentId !== deployment.id
2755 ? app.latestDeploymentId
2756 : null);
2757 const rollbackDeploymentId =
2758 previousDeploymentId === terminalFailedDeploymentId
2759 ? null
2760 : previousDeploymentId;
2761
2762 if (effectiveNews.start ?? true) {
2763 const currentDeployment = deployment;
2764 const currentDeploymentId = currentDeployment.id;
2765 deployment = yield* Effect.gen(function* () {
2766 if (
2767 currentDeployment.status !== "running" &&
2768 currentDeployment.status !== "provisioning"
2769 ) {
2770 yield* startDeploymentIdempotent(currentDeployment.id);
2771 }
2772 const running = yield* waitForDeploymentStatus(
2773 currentDeployment.id,
2774 "running",
2775 effectiveNews,
2776 );
2777 yield* waitForDeploymentUrl(
2778 toDeploymentUrl(running.previewDomain),
2779 effectiveNews,
2780 );
2781 return running;
2782 }).pipe(
2783 Effect.catch((error) =>
2784 cleanupCreatedDeploymentOnFailure(currentDeploymentId, error),
2785 ),
2786 );
2787 }
2788
2789 let appEndpointDomain = app.appEndpointDomain;
2790 let previousDeploymentAction:
2791 | "stopped"
2792 | "destroyed"
2793 | "still-active"
2794 | null = previousDeploymentId ? "still-active" : null;
2795 let promoted = app.latestDeploymentId === deployment.id;
2796 if (promotionRequested) {
2797 // Replay promotion even when latestDeploymentId already matches.
2798 // Promotion also repairs endpoint/custom-domain routing drift.
2799 preserveCreatedAppOnFailure = true;
2800 const promotedApp = yield* promoteAppObserved(
2801 app.id,
2802 deployment.id,
2803 effectiveNews,
2804 );
2805 appEndpointDomain = promotedApp.appEndpointDomain;
2806 promoted = true;
2807 }
2808
2809 const deploymentUrl = toDeploymentUrl(deployment.previewDomain);
2810 const appUrl =
2811 toDeploymentUrl(appEndpointDomain) ??
2812 toDeploymentUrl(deployment.previewDomain);
2813 let readinessStatus: "ready" | "pending" | "skipped" = "skipped";
2814 if (promoted && effectiveNews.verifyUrl !== false) {
2815 const readiness = yield* waitForDeploymentUrl(
2816 appUrl,
2817 effectiveNews,
2818 ).pipe(Effect.result);
2819 if (Result.isSuccess(readiness)) {
2820 readinessStatus = "ready";
2821 } else if (rollbackDeploymentId) {
2822 const rollback = yield* createServiceRollback({
2823 serviceId: app.id,
2824 deploymentId: rollbackDeploymentId,
2825 }).pipe(
2826 Effect.map((response) => response.data),
2827 Effect.result,
2828 );
2829 const observedAfterRollback = yield* waitForAppDeploymentTarget(
2830 app.id,
2831 rollbackDeploymentId,
2832 effectiveNews,
2833 ).pipe(Effect.result);
2834 if (Result.isSuccess(observedAfterRollback)) {
2835 return yield* cleanupCreatedDeploymentOnFailure(
2836 deployment.id,
2837 Result.isSuccess(rollback)
2838 ? readiness.failure
2839 : new AggregateError(
2840 [readiness.failure, rollback.failure],
2841 `Prisma App '${app.id}' endpoint was restored to deployment '${rollbackDeploymentId}', but the rollback response was lost.`,
2842 ),
2843 );
2844 }
2845
2846 return yield* Effect.fail(
2847 new AggregateError(
2848 [
2849 readiness.failure,
2850 ...(Result.isFailure(rollback) ? [rollback.failure] : []),
2851 observedAfterRollback.failure,
2852 ],
2853 `Prisma App '${app.id}' promoted deployment '${deployment.id}', but its stable endpoint failed readiness and rollback to deployment '${rollbackDeploymentId}' did not converge. Alchemy preserved the prior deployment in state and deleted neither deployment; the next reconcile will retry recovery via POST /v1/services/${app.id}/rollback before making any new cloud changes.`,
2854 ),
2855 );
2856 } else {
2857 // A first deployment has no safe rollback target. Never report
2858 // a successful stack deployment while the production endpoint
2859 // is unreachable. A newly created App can be fully cleaned up;
2860 // a pre-existing App is preserved for observed-state recovery.
2861 if (ensuredApp.created) {
2862 preserveCreatedAppOnFailure = false;
2863 } else {
2864 deploymentConverged = true;
2865 }
2866 if (terminalFailedDeploymentId !== undefined) {
2867 return yield* Effect.fail(
2868 new AggregateError(
2869 [readiness.failure],
2870 `Prisma App '${app.id}' promoted replacement deployment '${deployment.id}', but stable readiness failed and persisted deployment '${terminalFailedDeploymentId}' is terminal failed, so no safe rollback target exists. Neither deployment was deleted; a retry will refuse to create another generation while the live target differs from persisted state.`,
2871 ),
2872 );
2873 }
2874 return yield* Effect.fail(readiness.failure);
2875 }
2876 }
2877
2878 deploymentConverged = true;
2879 let pendingDeploymentCleanup =
2880 promoted &&
2881 previousDeploymentId &&
2882 previousDeploymentId !== deployment.id
2883 ? {
2884 deploymentId: previousDeploymentId,
2885 action:
2886 persistedPendingCleanup?.deploymentId ===
2887 previousDeploymentId
2888 ? persistedPendingCleanup.action
2889 : previousDeploymentId === terminalFailedDeploymentId
2890 ? ("destroy" as const)
2891 : effectiveNews.destroyOldDeployment
2892 ? ("destroy" as const)
2893 : ("stop" as const),
2894 }
2895 : undefined;
2896 if (promoted && pendingDeploymentCleanup) {
2897 const cleanup = yield* cleanupPreviousDeployment(
2898 pendingDeploymentCleanup.deploymentId,
2899 pendingDeploymentCleanup.action,
2900 ).pipe(Effect.result);
2901 if (Result.isSuccess(cleanup)) {
2902 previousDeploymentAction = cleanup.success;
2903 pendingDeploymentCleanup = undefined;
2904 } else {
2905 yield* Effect.logWarning(
2906 `Prisma deployment '${pendingDeploymentCleanup.deploymentId}' cleanup is pending and will be retried before another Compute generation is created.`,
2907 );
2908 }
2909 }
2910
2911 return {
2912 appId: app.id,
2913 deploymentId: deployment.id,
2914 projectId,
2915 appName: app.name,
2916 regionId: app.region.id,
2917 deploymentEndpointDomain: deployment.previewDomain ?? undefined,
2918 deploymentUrl,
2919 appEndpointDomain,
2920 url: promoted ? appUrl : deploymentUrl,
2921 promoted,
2922 previousDeploymentId,
2923 previousDeploymentAction,
2924 readinessStatus,
2925 pendingDeploymentCleanup,
2926 environmentKeys: managedEnvKeys(effectiveNews.env),
2927 environmentVariableIds: environmentResult.ownedIds,
2928 environmentClass: currentEnvironmentScope.class,
2929 environmentBranchId: currentEnvironmentScope.branchId,
2930 artifactHash: Redacted.make(deploymentHash),
2931 local: false,
2932 };
2933 }).pipe(
2934 Effect.catch((error) =>
2935 deploymentConverged
2936 ? Effect.fail(error)
2937 : rollbackCreatedEnvironmentVariables(
2938 createdEnvironmentVariableIds,
2939 error,
2940 ),
2941 ),
2942 Effect.catch(cleanupCreatedAppOnFailure),
2943 Effect.ensuring(cleanupArtifact),
2944 );
2945 }),
2946 delete: Effect.fn(function* ({ olds, output }) {
2947 if (output.local || isPrismaDevId(output.appId)) {
2948 yield* stopTrackedDevProcess(output.appId);
2949 return;
2950 }
2951 yield* destroyComputeEnvironment(
2952 output.projectId,
2953 environmentScope(
2954 olds?.envClass ?? output.environmentClass ?? "production",
2955 output.environmentBranchId,
2956 ),
2957 output.environmentVariableIds,
2958 );
2959 yield* destroyApp(output.appId);
2960 }),
2961 tail: ({ output }) =>
2962 output.deploymentId
2963 ? tailDeploymentLogs(output.deploymentId)
2964 : Stream.empty,
2965 };
2966 }),
2967 );
2968
2969export const ComputeDevProvider = () =>
2970 Provider.effect(
2971 Compute,
2972 Effect.gen(function* () {
2973 const ctx = yield* AlchemyContext;
2974 return {
2975 stables: ["appId"],
2976 list: () => Effect.succeed([]),
2977 diff: Effect.fn(function* () {
2978 return { action: "update" } as const;
2979 }),
2980 read: Effect.fn(function* ({ output }) {
2981 return output?.local ? output : undefined;
2982 }),
2983 reconcile: Effect.fn(function* ({ id, news, output, bindings }) {
2984 const bindingEnv = activeBindingEnv(bindings);
2985 const effectiveNews = {
2986 ...news,
2987 env: {
2988 ...bindingEnv,
2989 ...news.env,
2990 },
2991 };
2992 yield* validateComputeProps(effectiveNews);
2993 const projectId = yield* resolveProjectId(effectiveNews.project);
2994 if (!ctx.dev) {
2995 return yield* Effect.fail(
2996 new Error("ComputeDevProvider requires Alchemy dev mode."),
2997 );
2998 }
2999 const localUrl = yield* startDev(id, effectiveNews);
3000 return {
3001 appId: output?.appId ?? `dev:${id}`,
3002 deploymentId: undefined,
3003 projectId,
3004 appName: effectiveNews.appName ?? id,
3005 regionId: effectiveNews.regionId ?? "us-east-1",
3006 deploymentEndpointDomain: localUrl,
3007 deploymentUrl: localUrl,
3008 appEndpointDomain: localUrl,
3009 url: localUrl,
3010 promoted: false,
3011 previousDeploymentId: undefined,
3012 previousDeploymentAction: undefined,
3013 readinessStatus: "skipped",
3014 pendingDeploymentCleanup: undefined,
3015 environmentKeys: managedEnvKeys(effectiveNews.env),
3016 environmentVariableIds: {},
3017 environmentClass: effectiveNews.envClass ?? "production",
3018 environmentBranchId: null,
3019 artifactHash: undefined,
3020 local: true,
3021 };
3022 }),
3023 delete: Effect.fn(function* ({ output }) {
3024 yield* stopTrackedDevProcess(output.appId);
3025 }),
3026 };
3027 }),
3028 );
3029
3030export const ComputeProvider = () =>
3031 ProviderLayer.dual(Compute, {
3032 local: () => ComputeDevProvider(),
3033 live: () => ProviderLive(),
3034 });