1import * as Effect from "effect/Effect";
2import * as Path from "effect/Path";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../Diff.ts";
6import * as Provider from "../Provider.ts";
7import {
8 DEV_TIMESTAMP,
9 attrOrString,
10 devId,
11 devProvider,
12} from "./Internal/DevStub.ts";
13import * as ProviderLayer from "../Local/ProviderLayer.ts";
14import { Resource } from "../Resource.ts";
15import { sha256Object } from "../Util/sha256.ts";
16import {
17 type GetServiceDeploymentsResponse,
18 getServiceDeployments,
19 createServiceDeployment,
20} from "@distilled.cloud/prisma/management";
21import { Retry } from "@distilled.cloud/prisma";
22import {
23 destroyDeployment,
24 waitForDeploymentStatus,
25} from "./ComputeLifecycle.ts";
26import { executeArtifactUpload } from "./Internal/ArtifactUpload.ts";
27import { aggregateCleanupFailure } from "./Internal/CleanupFailure.ts";
28import {
29 inspectArtifactFile,
30 readArtifactFile,
31 type ArtifactFile,
32} from "./Internal/ArtifactFile.ts";
33import { promoteAppObserved } from "./Internal/AppPromotion.ts";
34import { startDeploymentIdempotent } from "./Internal/DeploymentActions.ts";
35import { ensureDeploymentMembership } from "./Internal/DeploymentIdentity.ts";
36import { observeDeployment } from "./Internal/DeploymentObserve.ts";
37import { tailDeploymentLogs } from "./PrismaLogs.ts";
38import type { App } from "./App.ts";
39import type { Providers } from "./Providers.ts";
40import {
41 concreteIdsChanged,
42 isInputObject,
43 isPrismaDevId,
44 resolveAppId,
45 unresolvedAppIdOf,
46} from "./Refs.ts";
47import type { ObservedDeployment } from "./Internal/Observed.ts";
48import { PrismaPaginationError } from "./Internal/Pagination.ts";
49
50export const MAX_DEPLOYMENT_ARTIFACT_BYTES = 256 * 1024 * 1024;
51
52export interface DeploymentProps {
53 /**
54 * App ID or Prisma.App resource that owns this deployment.
55 */
56 app: string | App;
57 /**
58 * Port mapping for the deployment. Set `http` to `null` to reset to
59 * Foundry's default port (8080); non-null values must be integers from
60 * 1 through 65535.
61 */
62 portMapping?: { http?: number | null };
63 /**
64 * Create the deployment by reusing the App's currently promoted artifact
65 * instead of uploading code. Requires an existing promoted deployment.
66 */
67 skipCodeUpload?: boolean;
68 /**
69 * Path to a pre-created artifact file to upload to the pre-signed upload URL.
70 */
71 artifactPath?: string;
72 /**
73 * Content type for artifact uploads.
74 *
75 * @default "application/octet-stream"
76 */
77 artifactContentType?: string;
78 /**
79 * Start the deployment after it is created.
80 *
81 * @default false
82 */
83 start?: boolean;
84 /**
85 * Promote the deployment to the App's stable endpoint after start.
86 * If `start` is omitted, enabling promotion starts the deployment first.
87 *
88 * @default false
89 */
90 promote?: boolean;
91 /**
92 * Opaque key/value map; when any resolved value changes, a replacement
93 * deployment is planned (create-before-delete) even if the artifact is
94 * unchanged — the same contract as `AWS.ApiGateway.Deployment.triggers`.
95 * Because values here may be secrets, they are folded into the deployment
96 * fingerprint as a salted hash and persisted `Redacted` rather than
97 * compared as plaintext; plaintext never lands in state.
98 *
99 * Prisma snapshots environment variables into a deployment when the
100 * deployment is created, so changing only an environment variable's value
101 * updates the platform's variable record but never reaches the running app.
102 * Pass those values here to make such a change take effect.
103 *
104 * Members may be wrapped in `Redacted.make(secret)`; they are unwrapped only
105 * to compute the fingerprint.
106 *
107 * If the value cannot be resolved while the deploy is being planned —
108 * because it reads an attribute of another resource that the same deploy is
109 * changing — the diff cannot prove the fingerprint is unchanged, and the
110 * engine offers no later opportunity to plan a replacement. The deployment
111 * is then replaced conservatively, but only once a fingerprint has already
112 * been recorded: adding `triggers` to an existing deployment records its
113 * fingerprint through a plain update rather than forcing a replacement.
114 *
115 * Leaving `triggers` unset tracks nothing, so removing it from a
116 * deployment that had it does not trigger a replacement on its own.
117 *
118 * @example
119 * ```typescript
120 * const deployment = yield* Prisma.Deployment("web", {
121 * app,
122 * artifactPath: "./dist/app.tar.gz",
123 * triggers: { DATABASE_URL: Redacted.make(databaseUrl) },
124 * start: true,
125 * promote: true,
126 * });
127 * ```
128 */
129 triggers?: Record<string, unknown>;
130}
131
132export interface Deployment extends Resource<
133 "Prisma.Deployment",
134 DeploymentProps,
135 {
136 /**
137 * Prisma deployment ID.
138 */
139 deploymentId: string;
140 /**
141 * App ID that owns the deployment.
142 */
143 appId: string;
144 /**
145 * Foundry version ID returned by the Prisma Management API.
146 */
147 foundryVersionId: string;
148 /**
149 * Current deployment status, when observed.
150 */
151 status: string | undefined;
152 /**
153 * Preview endpoint domain for the deployment.
154 */
155 previewDomain: string | null | undefined;
156 /**
157 * Hash of the artifact bytes uploaded for this deployment, when Alchemy
158 * uploaded an artifact.
159 */
160 artifactHash?: string;
161 /**
162 * Salted fingerprint of the resolved `triggers` inputs this deployment
163 * was created for. Held `Redacted` so the inputs stay out of plaintext
164 * state; absent when `triggers` is unset.
165 */
166 triggersHash?: Redacted.Redacted<string>;
167 /**
168 * Stable App endpoint domain after promotion.
169 */
170 appEndpointDomain: string | undefined;
171 /**
172 * ISO timestamp when the deployment was created, when observed.
173 */
174 createdAt: string | undefined;
175 },
176 never,
177 Providers
178> {}
179
180/**
181 * A Prisma deployment owned by an App.
182 *
183 * This is the low-level resource: it can upload or reuse an artifact, start it,
184 * and promote it, but it does not provide `Prisma.Compute`'s preview/stable
185 * health checks or automatic rollback. Prefer `Prisma.Compute` for production
186 * application deployments.
187 *
188 * Prisma's create-deployment API currently exposes neither an idempotency key
189 * nor a caller-defined natural key. After a crash that loses state immediately
190 * after creation, Alchemy deliberately does not adopt the App's latest
191 * deployment: doing so could take ownership of an unrelated deployment. When
192 * persisted state contains a Foundry version ID, refresh may safely recover the
193 * matching deployment.
194 *
195 * ### Creating a Deployment
196 * **Example:** Fork the currently promoted artifact
197 * ```typescript
198 * const deployment = yield* Prisma.Deployment("web-v2", {
199 * app: app.appId,
200 * skipCodeUpload: true,
201 * start: true,
202 * promote: true,
203 * });
204 * ```
205 *
206 * **Example:** Upload a prebuilt artifact
207 * ```typescript
208 * const deployment = yield* Prisma.Deployment("web-v3", {
209 * app: app.appId,
210 * artifactPath: "./dist/app.tar.gz",
211 * artifactContentType: "application/gzip",
212 * start: true,
213 * promote: true,
214 * });
215 * ```
216 *
217 * @resource
218 * @product Compute
219 */
220export const Deployment = Resource<Deployment>("Prisma.Deployment");
221
222// Distilled emits the cursor-paginated list operations as plain ops, so
223// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
224const listAppDeployments = (appId: string) =>
225 Effect.gen(function* () {
226 const deployments: GetServiceDeploymentsResponse["data"][number][] = [];
227 let cursor: string | undefined;
228 while (true) {
229 const page = yield* getServiceDeployments(
230 cursor === undefined
231 ? { serviceId: appId, limit: 100 }
232 : { serviceId: appId, limit: 100, cursor },
233 );
234 deployments.push(...page.data);
235 const nextCursor = page.pagination.nextCursor;
236 if (!page.pagination.hasMore) break;
237 if (nextCursor === null) {
238 return yield* Effect.fail(
239 new PrismaPaginationError({
240 message:
241 "Invalid Prisma Management API pagination response from getServiceDeployments: hasMore was true without a non-empty nextCursor",
242 }),
243 );
244 }
245 cursor = nextCursor;
246 }
247 return deployments;
248 });
249
250const findDeployment = (appId: string, foundryVersionId: string | undefined) =>
251 foundryVersionId === undefined
252 ? Effect.succeed(undefined)
253 : listAppDeployments(appId).pipe(
254 Effect.flatMap((deployments) => {
255 const matches = deployments.filter(
256 (deployment) => deployment.foundryVersionId === foundryVersionId,
257 );
258 return matches.length > 1
259 ? Effect.fail(
260 new Error(
261 `Prisma returned multiple deployments with Foundry version ID '${foundryVersionId}' for App '${appId}'; refusing an ambiguous recovery match.`,
262 ),
263 )
264 : Effect.succeed(matches[0]);
265 }),
266 );
267
268const attrsFrom = (
269 deployment: ObservedDeployment,
270 appId: string,
271 extra?: {
272 artifactHash?: string;
273 triggersHash?: Redacted.Redacted<string>;
274 appEndpointDomain?: string;
275 },
276): Deployment["Attributes"] => ({
277 deploymentId: deployment.id,
278 appId,
279 foundryVersionId: deployment.foundryVersionId,
280 status: deployment.status,
281 previewDomain: deployment.previewDomain,
282 artifactHash: extra?.artifactHash,
283 triggersHash: extra?.triggersHash,
284 appEndpointDomain: extra?.appEndpointDomain,
285 createdAt: deployment.createdAt,
286});
287
288export interface ReadUploadArtifactInput {
289 artifact?: string | Uint8Array;
290 artifactPath?: string;
291 output?: "bytes" | "file";
292}
293
294export function readUploadArtifact(
295 input: ReadUploadArtifactInput & { readonly output: "file" },
296): Effect.Effect<ArtifactFile | undefined, Error, Path.Path>;
297export function readUploadArtifact(
298 input: ReadUploadArtifactInput & { readonly output?: "bytes" },
299): Effect.Effect<Uint8Array | undefined, Error, Path.Path>;
300export function readUploadArtifact(
301 input: ReadUploadArtifactInput,
302): Effect.Effect<Uint8Array | ArtifactFile | undefined, Error, Path.Path>;
303export function readUploadArtifact(input: ReadUploadArtifactInput) {
304 return readUploadArtifactInternal(input);
305}
306
307const readUploadArtifactInternal = Effect.fn(function* (
308 input: ReadUploadArtifactInput,
309) {
310 if (input.artifact !== undefined && input.artifactPath !== undefined) {
311 return yield* Effect.fail(
312 new Error("artifact and artifactPath are mutually exclusive."),
313 );
314 }
315 if (input.output === "file" && input.artifact !== undefined) {
316 return yield* Effect.fail(
317 new Error("File-backed artifact output requires artifactPath."),
318 );
319 }
320 if (input.artifactPath !== undefined) {
321 const path = yield* Path.Path;
322 const resolved = path.resolve(input.artifactPath);
323 const artifact = yield* inspectArtifactFile(
324 resolved,
325 MAX_DEPLOYMENT_ARTIFACT_BYTES,
326 );
327 if (input.output === "file") {
328 return artifact;
329 }
330 return yield* readArtifactFile(artifact);
331 }
332 if (input.artifact !== undefined) {
333 const artifact = input.artifact;
334 const bytes = yield* Effect.sync(() =>
335 typeof artifact === "string"
336 ? new TextEncoder().encode(artifact)
337 : artifact,
338 );
339 return yield* validateDeploymentArtifactBytes(bytes);
340 }
341 return undefined;
342});
343
344export const validateDeploymentArtifactBytes = (
345 artifact: Uint8Array,
346 maxBytes = MAX_DEPLOYMENT_ARTIFACT_BYTES,
347) =>
348 !Number.isSafeInteger(maxBytes) || maxBytes <= 0
349 ? Effect.fail(
350 new Error("Artifact maxBytes must be a positive safe integer."),
351 )
352 : maxBytes > MAX_DEPLOYMENT_ARTIFACT_BYTES
353 ? Effect.fail(
354 new Error(
355 `Artifact maxBytes must not exceed the hard limit of ${MAX_DEPLOYMENT_ARTIFACT_BYTES}.`,
356 ),
357 )
358 : artifact.byteLength === 0
359 ? Effect.fail(
360 new Error("Prisma deployment artifact must be non-empty."),
361 )
362 : artifact.byteLength > maxBytes
363 ? Effect.fail(
364 new Error(
365 `Prisma deployment artifact exceeds the ${maxBytes} byte upload safety limit.`,
366 ),
367 )
368 : Effect.succeed(artifact);
369
370const artifactHashOf = Effect.fn(function* (props: DeploymentProps) {
371 const artifact = yield* readUploadArtifact({
372 artifactPath: props.artifactPath,
373 output: "file",
374 });
375 if (artifact === undefined) return undefined;
376 return yield* sha256Object({
377 artifact: artifact.sha256,
378 contentType: props.artifactContentType ?? "application/octet-stream",
379 });
380});
381
382/**
383 * Domain separator so a `triggers` fingerprint is never the bare SHA-256 of
384 * a secret value, which would otherwise be comparable against a precomputed
385 * digest of a guessed value.
386 */
387const TRIGGERS_HASH_SALT = "alchemy/Prisma.Deployment/triggers/v1";
388
389const unwrapRedacted = (value: unknown): unknown => {
390 if (Redacted.isRedacted(value)) return unwrapRedacted(Redacted.value(value));
391 if (Array.isArray(value)) return value.map(unwrapRedacted);
392 if (value !== null && typeof value === "object") {
393 return Object.fromEntries(
394 Object.entries(value).map(([key, item]) => [key, unwrapRedacted(item)]),
395 );
396 }
397 return value;
398};
399
400const triggersHashOf = (triggers: unknown) =>
401 sha256Object({
402 salt: TRIGGERS_HASH_SALT,
403 triggers: unwrapRedacted(triggers) ?? null,
404 });
405
406const persistedTriggersHash = (
407 value: Redacted.Redacted<string> | string | undefined,
408) => (Redacted.isRedacted(value) ? Redacted.value(value) : value);
409
410/**
411 * Whether the declared `triggers` inputs differ from the fingerprint the
412 * running deployment was created for.
413 *
414 * Only `diff` can plan a replacement — `reconcile` has no way to ask for one —
415 * so an input that is still unresolved while planning is treated as changed.
416 * That is only reached when another resource in the same deploy owns the
417 * value and is itself changing, in which case the value most likely changed
418 * too; the cost of being wrong is one extra create-before-delete replacement.
419 * Without a recorded fingerprint there is nothing to compare against, so the
420 * first deploy that declares `triggers` records it through the engine's
421 * default update instead of replacing the deployment.
422 */
423const triggersChanged = Effect.fn(function* (
424 triggers: unknown,
425 persisted: Redacted.Redacted<string> | string | undefined,
426) {
427 const recorded = persistedTriggersHash(persisted);
428 if (triggers === undefined || recorded === undefined) return false;
429 if (!isResolved({ triggers })) return true;
430 return (yield* triggersHashOf(triggers)) !== recorded;
431});
432
433export const uploadArtifact = (
434 uploadUrl: string,
435 artifact: Uint8Array | ArtifactFile,
436 contentType: string,
437) =>
438 Effect.gen(function* () {
439 if (artifact instanceof Uint8Array) {
440 yield* validateDeploymentArtifactBytes(artifact);
441 } else if (
442 artifact.size <= 0 ||
443 artifact.size > MAX_DEPLOYMENT_ARTIFACT_BYTES
444 ) {
445 return yield* Effect.fail(
446 new Error(
447 `Prisma deployment artifact exceeds the ${MAX_DEPLOYMENT_ARTIFACT_BYTES} byte upload safety limit.`,
448 ),
449 );
450 }
451 yield* Effect.try({
452 try: () => {
453 const parsed = new URL(uploadUrl);
454 if (
455 parsed.protocol !== "https:" ||
456 parsed.username.length > 0 ||
457 parsed.password.length > 0
458 ) {
459 throw new Error("invalid upload URL");
460 }
461 },
462 catch: () =>
463 new Error("Prisma artifact upload URL must be credential-free HTTPS."),
464 });
465 yield* executeArtifactUpload(uploadUrl, artifact, contentType);
466 });
467
468const ProviderLive = () =>
469 Provider.effect(
470 Deployment,
471 Effect.gen(function* () {
472 return {
473 stables: ["deploymentId"],
474 // App deletion cascades deployments. AppProvider is the single nuke
475 // enumerator so a deployment is never deleted twice during teardown.
476 list: () => Effect.succeed([]),
477 diff: Effect.fn(function* ({ olds, news, output }) {
478 if (!isInputObject(news)) return undefined;
479 if (isPrismaDevId(output?.deploymentId)) {
480 return { action: "update" } as const;
481 }
482 // `deploymentId` is stable and Foundry's failed status is terminal.
483 // Route drift recovery through the engine's create-before-delete
484 // replacement lifecycle instead of trying to restart the failed
485 // generation or changing a stable ID during an update.
486 if (output?.status === "failed") {
487 return { action: "replace" } as const;
488 }
489 // Checked before the resolved-content early return below: a
490 // deferred diff falls back to an update, which reuses the running
491 // deployment and would never apply the changed inputs.
492 if (yield* triggersChanged(news.triggers, output?.triggersHash)) {
493 return { action: "replace" } as const;
494 }
495 const replacementContent = {
496 portMapping: news.portMapping,
497 skipCodeUpload: news.skipCodeUpload,
498 artifactPath: news.artifactPath,
499 artifactContentType: news.artifactContentType,
500 };
501 if (!isResolved(replacementContent)) return undefined;
502 const resolvedReplacementContent = replacementContent as Pick<
503 DeploymentProps,
504 | "portMapping"
505 | "skipCodeUpload"
506 | "artifactPath"
507 | "artifactContentType"
508 >;
509 const oldAppId = output?.appId ?? unresolvedAppIdOf(olds.app);
510 const newAppId = isResolved(news.app)
511 ? unresolvedAppIdOf(news.app)
512 : undefined;
513 const oldArtifactHash = output?.artifactHash;
514 const newArtifactHash = yield* artifactHashOf({
515 app: olds.app,
516 ...resolvedReplacementContent,
517 });
518 const appChanged = concreteIdsChanged(oldAppId, newAppId);
519 if (
520 appChanged ||
521 !deepEqual(
522 resolvedReplacementContent.portMapping ?? {},
523 olds.portMapping ?? {},
524 ) ||
525 (resolvedReplacementContent.skipCodeUpload ?? false) !==
526 (olds.skipCodeUpload ?? false) ||
527 resolvedReplacementContent.artifactPath !== olds.artifactPath ||
528 resolvedReplacementContent.artifactContentType !==
529 olds.artifactContentType ||
530 (newArtifactHash !== undefined &&
531 newArtifactHash !== oldArtifactHash)
532 ) {
533 return { action: "replace" } as const;
534 }
535 const updateProps = {
536 start: news.start,
537 promote: news.promote,
538 };
539 if (!isResolved(updateProps)) return undefined;
540 const resolvedUpdateProps = updateProps as Pick<
541 DeploymentProps,
542 "start" | "promote"
543 >;
544 if (
545 (resolvedUpdateProps.start ?? false) ||
546 (resolvedUpdateProps.promote ?? false)
547 ) {
548 // Reconcile asserted lifecycle state on every deploy so external
549 // stops and App routing drift are repaired.
550 return { action: "update" } as const;
551 }
552 if (
553 (resolvedUpdateProps.start ?? false) !== (olds.start ?? false) ||
554 (resolvedUpdateProps.promote ?? false) !== (olds.promote ?? false)
555 ) {
556 return { action: "update" } as const;
557 }
558 return undefined;
559 }),
560 read: Effect.fn(function* ({ output, olds }) {
561 if (isPrismaDevId(output?.deploymentId)) return undefined;
562 const appId =
563 output?.appId && !isPrismaDevId(output.appId)
564 ? output.appId
565 : yield* resolveAppId(olds.app);
566 const savedDeployment = output?.deploymentId
567 ? yield* observeDeployment(output.deploymentId).pipe(
568 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
569 )
570 : undefined;
571 const listed = savedDeployment
572 ? undefined
573 : yield* findDeployment(appId, output?.foundryVersionId);
574 const deployment =
575 savedDeployment ??
576 (listed ? yield* observeDeployment(listed.id) : undefined);
577 if (savedDeployment) {
578 yield* ensureDeploymentMembership(appId, savedDeployment);
579 }
580 return deployment ? attrsFrom(deployment, appId, output) : undefined;
581 }),
582 reconcile: Effect.fn(function* ({ news, output }) {
583 if (
584 news.portMapping?.http !== undefined &&
585 news.portMapping.http !== null &&
586 (!Number.isInteger(news.portMapping.http) ||
587 news.portMapping.http < 1 ||
588 news.portMapping.http > 65_535)
589 ) {
590 return yield* Effect.fail(
591 new Error(
592 "portMapping.http must be an integer between 1 and 65535.",
593 ),
594 );
595 }
596 if (
597 !(news.skipCodeUpload ?? false) &&
598 news.artifactPath === undefined
599 ) {
600 return yield* Effect.fail(
601 new Error(
602 "Prisma.Deployment requires artifactPath or skipCodeUpload: true.",
603 ),
604 );
605 }
606 if (
607 (news.skipCodeUpload ?? false) &&
608 news.artifactPath !== undefined
609 ) {
610 return yield* Effect.fail(
611 new Error("skipCodeUpload cannot be combined with artifactPath."),
612 );
613 }
614 if ((news.promote ?? false) && news.start === false) {
615 return yield* Effect.fail(
616 new Error("promote cannot be combined with start: false."),
617 );
618 }
619 const artifact = yield* readUploadArtifact({
620 artifactPath: news.artifactPath,
621 output: "file",
622 });
623 const artifactHash =
624 artifact === undefined
625 ? output?.artifactHash
626 : yield* sha256Object({
627 artifact: artifact.sha256,
628 contentType:
629 news.artifactContentType ?? "application/octet-stream",
630 });
631 const triggersHash =
632 news.triggers === undefined
633 ? undefined
634 : Redacted.make(yield* triggersHashOf(news.triggers));
635 const appId = yield* resolveAppId(news.app);
636 const deploymentId = isPrismaDevId(output?.deploymentId)
637 ? undefined
638 : output?.deploymentId;
639 let deployment: ObservedDeployment | undefined = deploymentId
640 ? yield* observeDeployment(deploymentId).pipe(
641 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
642 )
643 : undefined;
644 if (deployment) {
645 yield* ensureDeploymentMembership(appId, deployment);
646 if (deployment.status === "failed") {
647 return yield* Effect.fail(
648 new Error(
649 `Prisma deployment '${deployment.id}' is in terminal status 'failed' and must be replaced before reconcile; refusing to restart it or change the stable deploymentId during an update.`,
650 ),
651 );
652 }
653 }
654 let createdDeploymentId: string | undefined;
655 const cleanupCreatedDeploymentOnFailure = (
656 failedDeploymentId: string,
657 error: unknown,
658 ) =>
659 createdDeploymentId === failedDeploymentId
660 ? destroyDeployment(failedDeploymentId).pipe(
661 Effect.catch((cleanupError) =>
662 Effect.fail(
663 aggregateCleanupFailure(
664 "deployment",
665 failedDeploymentId,
666 `/v1/deployments/${failedDeploymentId}`,
667 error,
668 cleanupError,
669 ),
670 ),
671 ),
672 Effect.andThen(() => Effect.fail(error)),
673 )
674 : Effect.fail(error);
675
676 if (!deployment) {
677 const created = yield* createServiceDeployment({
678 serviceId: appId,
679 ...(news.portMapping === undefined
680 ? {}
681 : { portMapping: news.portMapping }),
682 ...(news.skipCodeUpload === undefined
683 ? {}
684 : { skipCodeUpload: news.skipCodeUpload }),
685 }).pipe(
686 // A replayed create would make a second deployment; the retry
687 // policy cannot see the request, so opt out explicitly.
688 Retry.none,
689 Effect.map((response) => response.data),
690 );
691 createdDeploymentId = created.id;
692 if (artifact !== undefined && !created.uploadUrl) {
693 return yield* cleanupCreatedDeploymentOnFailure(
694 created.id,
695 new Error(
696 "Prisma deployment creation did not return an upload URL.",
697 ),
698 );
699 }
700 if (created.uploadUrl && artifact !== undefined) {
701 yield* uploadArtifact(
702 created.uploadUrl,
703 artifact,
704 news.artifactContentType ?? "application/octet-stream",
705 ).pipe(
706 Effect.catch((error) =>
707 cleanupCreatedDeploymentOnFailure(created.id, error),
708 ),
709 );
710 }
711 deployment = yield* observeDeployment(created.id).pipe(
712 Effect.catchTag("NotFound", () =>
713 Effect.succeed({
714 id: created.id,
715 foundryVersionId: created.foundryVersionId,
716 status: "new",
717 previewDomain: null,
718 createdAt: undefined,
719 }),
720 ),
721 Effect.catch((error) =>
722 cleanupCreatedDeploymentOnFailure(created.id, error),
723 ),
724 );
725 }
726 if (!deployment) {
727 return yield* Effect.fail(
728 new Error(
729 "Prisma deployment could not be resolved after creation.",
730 ),
731 );
732 }
733
734 let appEndpointDomain = output?.appEndpointDomain;
735 const shouldStart = news.start ?? news.promote ?? false;
736 if (shouldStart) {
737 const currentDeployment = deployment;
738 const currentDeploymentId = currentDeployment.id;
739 deployment = yield* Effect.gen(function* () {
740 if (
741 currentDeployment.status !== "running" &&
742 currentDeployment.status !== "provisioning"
743 ) {
744 const started = yield* startDeploymentIdempotent(
745 currentDeployment.id,
746 );
747 if (started) {
748 return yield* waitForDeploymentStatus(
749 currentDeployment.id,
750 "running",
751 ).pipe(
752 Effect.map((running) => ({
753 ...running,
754 previewDomain: started.previewDomain,
755 })),
756 );
757 }
758 }
759 return yield* waitForDeploymentStatus(
760 currentDeployment.id,
761 "running",
762 );
763 }).pipe(
764 Effect.catch((error) =>
765 cleanupCreatedDeploymentOnFailure(currentDeploymentId, error),
766 ),
767 );
768 }
769 if (news.promote ?? false) {
770 appEndpointDomain = yield* Effect.gen(function* () {
771 // Promotion is deliberately replayed even when the control-plane
772 // record already names this deployment. The endpoint operation also
773 // repairs provider routing and custom-domain assignment drift.
774 const promoted = yield* promoteAppObserved(appId, deployment.id);
775 return promoted.appEndpointDomain;
776 });
777 }
778
779 return attrsFrom(deployment, appId, {
780 artifactHash,
781 triggersHash,
782 appEndpointDomain,
783 });
784 }),
785 delete: Effect.fn(function* ({ output }) {
786 if (isPrismaDevId(output.deploymentId)) return;
787 const deployment = yield* observeDeployment(output.deploymentId).pipe(
788 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
789 );
790 if (!deployment) return;
791 yield* ensureDeploymentMembership(output.appId, deployment);
792 yield* destroyDeployment(output.deploymentId);
793 }),
794 tail: ({ output }) =>
795 output.deploymentId
796 ? tailDeploymentLogs(output.deploymentId)
797 : Stream.empty,
798 };
799 }),
800 );
801
802const ProviderLocal = () =>
803 devProvider(Deployment, ["deploymentId"], ({ id, news }) => ({
804 deploymentId: devId("deployment", id),
805 appId: attrOrString(news.app, "appId"),
806 foundryVersionId: devId("foundry-version", id),
807 status: "new",
808 previewDomain: undefined,
809 artifactHash: undefined,
810 triggersHash: undefined,
811 appEndpointDomain: undefined,
812 createdAt: DEV_TIMESTAMP,
813 }));
814
815export const DeploymentProvider = () =>
816 ProviderLayer.dual(Deployment, {
817 local: () => ProviderLocal(),
818 live: () => ProviderLive(),
819 });