import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { type InferOk, Result } from "better-result"; import invariant from "tiny-invariant"; import { InternalApiClient, readUploadUrl, toDeploymentUrl, } from "./api-client.ts"; import { createArchive } from "./archive.ts"; import type { BuildStrategy } from "./build-strategy.ts"; import type { DeployInteraction, DeployProgress, DestroyAppProgress, DestroyDeploymentInteraction, DestroyDeploymentProgress, PromoteProgress, UpdateEnvProgress, } from "./callbacks.ts"; import { ApiError, type ApiRequestError, ArtifactError, type AuthenticationError, BuildError, CancelledError, type DeployError, type DeploymentOperationError, DestroyAggregateError, type DestroyAppError, type DestroyDeploymentError, InvalidOptionsError, MissingArgumentError, NoDeploymentsFoundError, NoExistingDeploymentError, type PromoteError, type UpdateEnvError, } from "./errors.ts"; import { pollDeploymentStatus } from "./polling.ts"; import type { AppDetail, AppInfo, CreateProjectResult, DeploymentDetail, DeploymentInfo, PortMapping, ProjectInfo, ResolvedConfig, } from "./types.ts"; import { REGIONS } from "./types.ts"; import { uploadArtifact } from "./upload-artifact.ts"; type DeploymentDetailData = InferOk< Awaited> >; export interface DeployOptions { strategy: BuildStrategy; projectId?: string; appId?: string; appName?: string; region?: string; envVars?: Record; portMapping?: PortMapping; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DeployInteraction; progress?: DeployProgress; skipPromote?: boolean; destroyOldDeployment?: boolean; } export interface UpdateEnvOptions { projectId?: string; appId?: string; envVars?: Record; portMapping?: PortMapping; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DeployInteraction; progress?: UpdateEnvProgress; } export interface DestroyDeploymentOptions { deploymentId?: string; appId?: string; projectId?: string; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DestroyDeploymentInteraction; progress?: DestroyDeploymentProgress; } export interface DestroyAppOptions { appId: string; keepApp?: boolean; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; progress?: DestroyAppProgress; } export interface ListAppsOptions { projectId: string; signal?: AbortSignal; } export interface CreateAppOptions { projectId: string; appName: string; region: string; signal?: AbortSignal; } export interface ShowAppOptions { appId: string; signal?: AbortSignal; } export interface DeleteAppOptions { appId: string; signal?: AbortSignal; } export interface CreateProjectOptions { name: string; createDatabase?: boolean; region?: string; signal?: AbortSignal; } export interface ListProjectsOptions { signal?: AbortSignal; } export interface ListDeploymentsOptions { appId: string; signal?: AbortSignal; } export interface ShowDeploymentOptions { deploymentId: string; signal?: AbortSignal; } export interface StartDeploymentOptions { deploymentId: string; signal?: AbortSignal; } export interface StopDeploymentOptions { deploymentId: string; signal?: AbortSignal; } export interface DeleteDeploymentOptions { deploymentId: string; signal?: AbortSignal; } export interface PromoteOptions { appId: string; deploymentId?: string; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DestroyDeploymentInteraction; progress?: PromoteProgress; } export interface DeployResult { projectId: string; appId: string; appName: string; region: string; deploymentId: string; deploymentEndpointDomain: string; appEndpointDomain: string | null; promoted: boolean; previousDeploymentId: string | null; previousDeploymentAction: "stopped" | "destroyed" | "still-active" | null; resolvedConfig: ResolvedConfig; } export interface PromoteResult { appId: string; deploymentId: string; appEndpointDomain: string; deploymentStarted: boolean; } export interface UpdateEnvResult { projectId: string; appId: string; deploymentId: string; deploymentUrl: string; resolvedConfig: ResolvedConfig; } export interface DestroyDeploymentResult { deploymentId: string; previousStatus: string; stopped: boolean; deleted: boolean; } export interface DestroyAppResult { appId: string; deletedDeploymentIds: string[]; appDeleted: boolean; } const DEFAULT_TIMEOUT_SECONDS = 120; const DEFAULT_POLL_INTERVAL_MS = 1_000; export class ComputeClient { readonly #api: InternalApiClient; constructor(managementApiClient: ManagementApiClient) { this.#api = new InternalApiClient(managementApiClient); } async deploy( options: DeployOptions, ): Promise> { if (options.skipPromote && options.destroyOldDeployment) { return Result.err( new InvalidOptionsError({ message: "destroyOldDeployment cannot be combined with skipPromote — the old deployment stays active when promotion is skipped", }), ); } const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const target = yield* Result.await(self.#resolveDeployTarget(options)); options.progress?.onBuildStart?.(); const artifact = yield* Result.await( Result.tryPromise({ try: () => options.strategy.execute(options.signal), catch: (e) => new BuildError({ message: e instanceof Error ? e.message : String(e), }), }), ); options.progress?.onBuildComplete?.(artifact); try { yield* self.#checkAborted(options.signal); options.progress?.onArchiveCreating?.(); const archiveBytes = yield* Result.await( Result.tryPromise({ try: () => createArchive( artifact.directory, artifact.entrypoint, options.signal, ), catch: (e) => new ArtifactError({ message: e instanceof Error ? e.message : `Archive creation failed: ${String(e)}`, cause: e, }), }), ); options.progress?.onArchiveReady?.(archiveBytes.byteLength); yield* self.#checkAborted(options.signal); const portMapping = options.portMapping ?? (target.isNewApp && artifact.defaultPortMapping ? artifact.defaultPortMapping : undefined); yield* Result.await( self.#applyEnvVars( target.projectId, target.branchId, options.envVars, options.signal, ), ); const createResponse = yield* Result.await( self.#api.createAppDeployment( target.appId, portMapping ? { portMapping } : {}, options.signal, ), ); options.progress?.onDeploymentCreated?.(createResponse.id); const uploadUrl = readUploadUrl(createResponse); if (!uploadUrl) { return Result.err( new ArtifactError({ message: "Deployment creation did not return an upload URL", }), ); } yield* self.#checkAborted(options.signal); options.progress?.onUploadStart?.(); yield* Result.await( self.#uploadArtifactResult(uploadUrl, archiveBytes, options.signal), ); options.progress?.onUploadComplete?.(); yield* self.#checkAborted(options.signal); const app = yield* Result.await( self.#api.getApp(target.appId, options.signal), ); const previousDeploymentId = app.latestDeploymentId ?? null; options.progress?.onStartRequested?.(); yield* Result.await( self.#api.startDeployment(createResponse.id, options.signal), ); const pollResult = yield* Result.await( pollDeploymentStatus(self.#api, createResponse.id, { targetStatus: "running", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, onStatusChange: options.progress?.onStatusChange, }), ); const deploymentEndpointDomain = toDeploymentUrl( pollResult.previewDomain, ); options.progress?.onRunning?.(deploymentEndpointDomain); const resolvedConfig: ResolvedConfig = { projectId: target.projectId, appId: target.appId, appName: target.appName, region: target.region, portMapping, }; if (options.skipPromote) { return Result.ok({ projectId: target.projectId, appId: target.appId, appName: target.appName, region: target.region, deploymentId: createResponse.id, deploymentEndpointDomain, appEndpointDomain: null, promoted: false, previousDeploymentId, previousDeploymentAction: previousDeploymentId ? ("still-active" as const) : null, resolvedConfig, }); } const switchoverResult = yield* Result.await( self.#promoteAndSwitchover( target.appId, createResponse.id, previousDeploymentId, options.destroyOldDeployment ?? false, options.signal, options.progress, ), ); return Result.ok({ projectId: target.projectId, appId: target.appId, appName: target.appName, region: target.region, deploymentId: createResponse.id, deploymentEndpointDomain, appEndpointDomain: switchoverResult.appEndpointDomain, promoted: true, previousDeploymentId: switchoverResult.previousDeploymentId, previousDeploymentAction: switchoverResult.previousDeploymentAction, resolvedConfig, }); } finally { await artifact.cleanup?.(); } }); } async updateEnv( options: UpdateEnvOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const target = yield* Result.await(self.#resolveDeployTarget(options)); const deployments = yield* Result.await( self.#api.listAppDeployments(target.appId, options.signal), ); if (deployments.length === 0) { return Result.err( new NoExistingDeploymentError({ appId: target.appId }), ); } yield* self.#checkAborted(options.signal); yield* Result.await( self.#applyEnvVars( target.projectId, target.branchId, options.envVars, options.signal, ), ); yield* self.#checkAborted(options.signal); const createResponse = yield* Result.await( self.#api.createAppDeployment( target.appId, { ...(options.portMapping !== undefined ? { portMapping: options.portMapping } : {}), skipCodeUpload: true, }, options.signal, ), ); options.progress?.onDeploymentCreated?.(createResponse.id); yield* self.#checkAborted(options.signal); options.progress?.onStartRequested?.(); yield* Result.await( self.#api.startDeployment(createResponse.id, options.signal), ); const pollResult = yield* Result.await( pollDeploymentStatus(self.#api, createResponse.id, { targetStatus: "running", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, onStatusChange: options.progress?.onStatusChange, }), ); const deploymentUrl = toDeploymentUrl(pollResult.previewDomain); options.progress?.onRunning?.(deploymentUrl); return Result.ok({ projectId: target.projectId, appId: target.appId, deploymentId: createResponse.id, deploymentUrl, resolvedConfig: { projectId: target.projectId, appId: target.appId, appName: target.appName, region: target.region, portMapping: options.portMapping, }, }); }); } async destroyDeployment( options: DestroyDeploymentOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); let deploymentId = options.deploymentId; let deploymentDetailsCache: Map | undefined; if (!deploymentId) { const appId = options.appId; if (!appId) { return Result.err( new MissingArgumentError({ field: "deploymentId" }), ); } const deployments = yield* Result.await( self.#api.listAppDeployments(appId, options.signal), ); const deploymentDetailResults = await Promise.all( deployments.map((d) => self.#api.getDeployment(d.id, options.signal)), ); const details: DeploymentDetailData[] = []; for (const result of deploymentDetailResults) { if (result.isErr()) return result; details.push(result.value); } deploymentDetailsCache = new Map(details.map((d) => [d.id, d])); const deploymentInfos = details .map( (d): DeploymentInfo => ({ id: d.id, status: d.status, createdAt: d.createdAt, previewDomain: d.previewDomain, }), ) .sort(deploymentSortComparator); if (deploymentInfos.length === 0) { return Result.err(new NoDeploymentsFoundError({ appId })); } if (!options.interaction?.selectDeployment) { return Result.err( new MissingArgumentError({ field: "deploymentId" }), ); } deploymentId = await options.interaction.selectDeployment(deploymentInfos); } const deployment = deploymentDetailsCache?.get(deploymentId) ?? (yield* Result.await( self.#api.getDeployment(deploymentId, options.signal), )); const previousStatus = deployment.status ?? "unknown"; const shouldStop = deployment.status === "running" || deployment.status === "provisioning"; let stopped = false; if (shouldStop) { options.progress?.onStopRequested?.(deploymentId); yield* Result.await( self.#api.stopDeployment(deploymentId, options.signal), ); yield* Result.await( pollDeploymentStatus(self.#api, deploymentId, { targetStatus: "stopped", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, }), ); stopped = true; options.progress?.onStopped?.(deploymentId); } yield* Result.await( self.#api.deleteDeployment(deploymentId, options.signal), ); options.progress?.onDeleted?.(deploymentId); return Result.ok({ deploymentId, previousStatus, stopped, deleted: true, }); }); } async destroyApp( options: DestroyAppOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; const deployments = yield* Result.await( self.#api.listAppDeployments(options.appId, options.signal), ); const deploymentDetailResults = await Promise.all( deployments.map((d) => self.#api.getDeployment(d.id, options.signal)), ); const deploymentDetails: DeploymentDetailData[] = []; for (const result of deploymentDetailResults) { if (result.isErr()) return result; deploymentDetails.push(result.value); } const activeDeployments = deploymentDetails.filter( (d) => d.status === "running" || d.status === "provisioning", ); const succeededIds: string[] = []; const failures: Array<{ deploymentId: string; error: DeploymentOperationError; }> = []; if (activeDeployments.length > 0) { const activeIds = activeDeployments.map((d) => d.id); options.progress?.onStoppingDeployments?.(activeIds); const stopResults = await Promise.all( activeDeployments.map((d) => self.#api.stopDeployment(d.id, options.signal), ), ); const stoppedCandidates: typeof activeDeployments = []; for (const [i, result] of stopResults.entries()) { // biome-ignore lint/style/noNonNullAssertion: both lists have the same length const activeDeployment = activeDeployments[i]!; if (result.isErr()) { failures.push({ deploymentId: activeDeployment.id, error: result.error, }); } else { stoppedCandidates.push(activeDeployment); } } if (stoppedCandidates.length > 0) { const pollResults = await Promise.all( stoppedCandidates.map((d) => pollDeploymentStatus(self.#api, d.id, { targetStatus: "stopped", timeoutSeconds, pollIntervalMs, signal: options.signal, }), ), ); for (const [i, result] of pollResults.entries()) { // biome-ignore lint/style/noNonNullAssertion: both lists have the same length const stoppedDeployment = stoppedCandidates[i]!; if (result.isErr()) { failures.push({ deploymentId: stoppedDeployment.id, error: result.error, }); } else { options.progress?.onDeploymentStopped?.(stoppedDeployment.id); } } } if (failures.length > 0) { return Result.err( new DestroyAggregateError({ succeededDeploymentIds: succeededIds, failures, appDeleted: false, }), ); } options.progress?.onAllDeploymentsStopped?.(); } if (deployments.length > 0) { const deploymentIds = deployments.map((d) => d.id); options.progress?.onDeletingDeployments?.(deploymentIds); const deleteResults = await Promise.all( deployments.map((d) => self.#api.deleteDeployment(d.id, options.signal), ), ); for (const [i, result] of deleteResults.entries()) { // biome-ignore lint/style/noNonNullAssertion: both lists have the same length const deployment = deployments[i]!; if (result.isErr()) { failures.push({ deploymentId: deployment.id, error: result.error, }); } else { succeededIds.push(deployment.id); options.progress?.onDeploymentDeleted?.(deployment.id); } } if (failures.length > 0) { return Result.err( new DestroyAggregateError({ succeededDeploymentIds: succeededIds, failures, appDeleted: false, }), ); } options.progress?.onAllDeploymentsDeleted?.(); } let appDeleted = false; if (!options.keepApp) { yield* Result.await(self.#api.deleteApp(options.appId, options.signal)); appDeleted = true; options.progress?.onAppDeleted?.(options.appId); } return Result.ok({ appId: options.appId, deletedDeploymentIds: succeededIds, appDeleted, }); }); } async createProject( options: CreateProjectOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const createDatabase = options.createDatabase ?? false; const response = yield* Result.await( self.#api.createProject( { name: options.name, createDatabase, region: options.region, }, options.signal, ), ); const result: CreateProjectResult = { id: response.id, name: response.name, defaultRegion: response.defaultRegion ?? undefined, }; if (response.database) { const connection = response.database.connections?.[0]; result.database = { id: response.database.id, name: response.database.name, region: response.database.region.id, connectionString: connection?.endpoints?.direct?.connectionString ?? undefined, }; } return Result.ok(result); }); } async listProjects( options: ListProjectsOptions = {}, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const projects = yield* Result.await( self.#api.listProjects(options.signal), ); return Result.ok( projects.map( (p): ProjectInfo => ({ id: p.id, name: p.name, defaultRegion: p.defaultRegion ?? undefined, }), ), ); }); } async listApps( options: ListAppsOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const apps = yield* Result.await( self.#api.listProjectApps(options.projectId, options.signal), ); return Result.ok( apps.map( (a): AppInfo => ({ id: a.id, name: a.name, region: a.region.id, projectId: a.projectId, }), ), ); }); } async createApp( options: CreateAppOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const response = yield* Result.await( self.#api.createProjectApp( options.projectId, { displayName: options.appName, regionId: options.region, }, options.signal, ), ); return Result.ok({ id: response.id, name: response.name, region: response.region.id, projectId: options.projectId, }); }); } async showApp( options: ShowAppOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const a = yield* Result.await( self.#api.getApp(options.appId, options.signal), ); return Result.ok({ id: a.id, name: a.name, region: a.region.id, projectId: a.projectId, latestDeploymentId: a.latestDeploymentId, appEndpointDomain: a.appEndpointDomain, }); }); } async deleteApp( options: DeleteAppOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await(self.#api.deleteApp(options.appId, options.signal)); return Result.ok(); }); } async listDeployments( options: ListDeploymentsOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const deployments = yield* Result.await( self.#api.listAppDeployments(options.appId, options.signal), ); const detailResults = await Promise.all( deployments.map((d) => self.#api.getDeployment(d.id, options.signal)), ); const details: DeploymentDetailData[] = []; for (const result of detailResults) { if (result.isErr()) { // listAppDeployments returns deployments that have previously been deleted // and can't be fetched using getDeployment so we need to filter those out. if (ApiError.is(result.error) && result.error.statusCode === 404) continue; return result; } details.push(result.value); } return Result.ok( details.map( (d): DeploymentInfo => ({ id: d.id, status: d.status, createdAt: d.createdAt, previewDomain: d.previewDomain, }), ), ); }); } async showDeployment( options: ShowDeploymentOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const d = yield* Result.await( self.#api.getDeployment(options.deploymentId, options.signal), ); return Result.ok({ id: d.id, status: d.status, createdAt: d.createdAt, previewDomain: d.previewDomain, envVars: d.envVars ?? undefined, }); }); } async startDeployment( options: StartDeploymentOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await( self.#api.startDeployment(options.deploymentId, options.signal), ); return Result.ok(); }); } async stopDeployment( options: StopDeploymentOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await( self.#api.stopDeployment(options.deploymentId, options.signal), ); return Result.ok(); }); } async deleteDeployment( options: DeleteDeploymentOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await( self.#api.deleteDeployment(options.deploymentId, options.signal), ); return Result.ok(); }); } async promote( options: PromoteOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); let deploymentId = options.deploymentId; if (!deploymentId) { const deployments = yield* Result.await( self.#api.listAppDeployments(options.appId, options.signal), ); const detailResults = await Promise.all( deployments.map((d) => self.#api.getDeployment(d.id, options.signal)), ); const details: DeploymentDetailData[] = []; for (const result of detailResults) { if (result.isErr()) { if (ApiError.is(result.error) && result.error.statusCode === 404) continue; return result; } details.push(result.value); } const deploymentInfos = details .map( (d): DeploymentInfo => ({ id: d.id, status: d.status, createdAt: d.createdAt, previewDomain: d.previewDomain, }), ) .sort(deploymentSortComparator); if (deploymentInfos.length === 0) { return Result.err( new NoDeploymentsFoundError({ appId: options.appId }), ); } if (!options.interaction?.selectDeployment) { return Result.err( new MissingArgumentError({ field: "deploymentId" }), ); } deploymentId = await options.interaction.selectDeployment(deploymentInfos); } const deployment = yield* Result.await( self.#api.getDeployment(deploymentId, options.signal), ); let deploymentStarted = false; if (deployment.status !== "running") { options.progress?.onDeploymentStarting?.(deploymentId); if (deployment.status !== "provisioning") { options.progress?.onDeploymentStartRequested?.(); const startResult = await self.#api.startDeployment( deploymentId, options.signal, ); if (startResult.isErr()) { options.progress?.onPromoteFailed?.(startResult.error); return startResult; } } const pollResult = await pollDeploymentStatus(self.#api, deploymentId, { targetStatus: "running", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, onStatusChange: options.progress?.onStatusChange, }); if (pollResult.isErr()) { options.progress?.onPromoteFailed?.(pollResult.error); return pollResult; } deploymentStarted = true; options.progress?.onDeploymentRunning?.(); } options.progress?.onPromoteStart?.(); const promoteResult = await self.#api.promoteApp( options.appId, { deploymentId }, options.signal, ); if (promoteResult.isErr()) { options.progress?.onPromoteFailed?.(promoteResult.error); return promoteResult; } options.progress?.onPromoted?.(promoteResult.value.appEndpointDomain); return Result.ok({ appId: options.appId, deploymentId, appEndpointDomain: promoteResult.value.appEndpointDomain, deploymentStarted, }); }); } async #promoteAndSwitchover( appId: string, newDeploymentId: string, previousDeploymentId: string | null, destroyOldDeployment: boolean, signal?: AbortSignal, progress?: DeployProgress, ): Promise< Result< { appEndpointDomain: string; previousDeploymentId: string | null; previousDeploymentAction: "stopped" | "destroyed" | null; }, DeployError > > { progress?.onPromoteStart?.(); const promoteResult = await this.#api.promoteApp( appId, { deploymentId: newDeploymentId }, signal, ); if (promoteResult.isErr()) { progress?.onPromoteFailed?.(promoteResult.error); progress?.onCleanupDanglingDeployment?.(newDeploymentId); await this.#cleanupDanglingDeployment( newDeploymentId, undefined, progress, ); return promoteResult; } const appEndpointDomain = promoteResult.value.appEndpointDomain; progress?.onPromoted?.(appEndpointDomain); let previousDeploymentAction: "stopped" | "destroyed" | null = null; if (previousDeploymentId && previousDeploymentId !== newDeploymentId) { progress?.onOldDeploymentStopping?.(previousDeploymentId); const stopResult = await this.#api.stopDeployment( previousDeploymentId, signal, ); if (stopResult.isOk()) { const pollResult = await pollDeploymentStatus( this.#api, previousDeploymentId, { targetStatus: "stopped", timeoutSeconds: 30, pollIntervalMs: 1000, signal, }, ); if (pollResult.isOk()) { previousDeploymentAction = "stopped"; progress?.onOldDeploymentStopped?.(previousDeploymentId); if (destroyOldDeployment) { progress?.onOldDeploymentDeleting?.(previousDeploymentId); const deleteResult = await this.#api.deleteDeployment( previousDeploymentId, signal, ); if (deleteResult.isOk()) { previousDeploymentAction = "destroyed"; progress?.onOldDeploymentDeleted?.(previousDeploymentId); } else { progress?.onOldDeploymentDeleteFailed?.(previousDeploymentId); } } } else { progress?.onOldDeploymentStopFailed?.(previousDeploymentId); } } else { progress?.onOldDeploymentStopFailed?.(previousDeploymentId); } } return Result.ok({ appEndpointDomain, previousDeploymentId, previousDeploymentAction, }); } async #cleanupDanglingDeployment( deploymentId: string, signal?: AbortSignal, progress?: DeployProgress, ): Promise { const stopResult = await this.#api.stopDeployment(deploymentId, signal); if (stopResult.isErr()) { progress?.onCleanupDanglingDeploymentFailed?.(deploymentId); return; } const pollResult = await pollDeploymentStatus(this.#api, deploymentId, { targetStatus: "stopped", timeoutSeconds: 30, pollIntervalMs: 1000, signal, }); if (pollResult.isErr()) { progress?.onCleanupDanglingDeploymentFailed?.(deploymentId); return; } const deleteResult = await this.#api.deleteDeployment(deploymentId, signal); if (deleteResult.isErr()) { progress?.onCleanupDanglingDeploymentFailed?.(deploymentId); return; } progress?.onCleanupDanglingDeploymentComplete?.(deploymentId); } #checkAborted(signal?: AbortSignal): Result { try { signal?.throwIfAborted(); return Result.ok(undefined); } catch { return Result.err(new CancelledError()); } } async #resolveDeployTarget(options: { projectId?: string; appId?: string; appName?: string; region?: string; signal?: AbortSignal; interaction?: DeployInteraction; }): Promise< Result< { projectId: string; appId: string; appName: string; region: string; branchId: string | null; isNewApp: boolean; }, | MissingArgumentError | CancelledError | AuthenticationError | ApiError | InvalidOptionsError > > { const self = this; return Result.gen(async function* () { if (options.appId) { const app = yield* Result.await( self.#api.getApp(options.appId, options.signal), ); if (!app.projectId) { return Result.err(new MissingArgumentError({ field: "projectId" })); } const requestedProjectId = options.projectId?.replace(/^proj_/, ""); const appProjectId = app.projectId.replace(/^proj_/, ""); if ( requestedProjectId !== undefined && requestedProjectId !== appProjectId ) { return Result.err( new InvalidOptionsError({ message: `App ${app.id} belongs to project ${app.projectId}, but project ${options.projectId} was provided`, }), ); } return Result.ok({ projectId: app.projectId, appId: app.id, appName: app.name ?? app.id, region: app.region.id, branchId: app.branchId ?? null, isNewApp: false, }); } let projectId = options.projectId; if (!projectId) { const projects = yield* Result.await( self.#api.listProjects(options.signal), ); const projectInfos = projects.map( (p): ProjectInfo => ({ id: p.id, name: p.name, defaultRegion: p.defaultRegion ?? undefined, }), ); if (!options.interaction?.selectProject) { return Result.err(new MissingArgumentError({ field: "projectId" })); } projectId = await options.interaction.selectProject(projectInfos); } if (options.appName && options.region) { const response = yield* Result.await( self.#api.createProjectApp( projectId, { displayName: options.appName, regionId: options.region, }, options.signal, ), ); return Result.ok({ projectId, appId: response.id, appName: response.name, region: response.region.id, branchId: response.branchId ?? null, isNewApp: true, }); } const apps = yield* Result.await( self.#api.listProjectApps(projectId, options.signal), ); const appInfos = apps.map( (a): AppInfo => ({ id: a.id, name: a.name, region: a.region.id, projectId: a.projectId, }), ); if (!options.interaction?.selectApp) { return Result.err(new MissingArgumentError({ field: "appId" })); } const selectedAppId = await options.interaction.selectApp(appInfos); if (selectedAppId !== null) { const selected = apps.find((a) => a.id === selectedAppId); invariant(selected, "selectApp returned app ID not in the list"); const app = yield* Result.await( self.#api.getApp(selectedAppId, options.signal), ); return Result.ok({ projectId, appId: selectedAppId, appName: app.name ?? selected.name, region: app.region.id, branchId: app.branchId ?? null, isNewApp: false, }); } let appName = options.appName; if (!appName) { if (!options.interaction?.provideAppName) { return Result.err(new MissingArgumentError({ field: "appName" })); } appName = await options.interaction.provideAppName(); } let region = options.region; if (!region) { if (!options.interaction?.selectRegion) { return Result.err(new MissingArgumentError({ field: "region" })); } region = await options.interaction.selectRegion(REGIONS); } const response = yield* Result.await( self.#api.createProjectApp( projectId, { displayName: appName, regionId: region, }, options.signal, ), ); return Result.ok({ projectId, appId: response.id, appName: response.name, region: response.region.id, branchId: response.branchId ?? null, isNewApp: true, }); }); } async #applyEnvVars( projectId: string, branchId: string | null, envVars: Record | undefined, signal?: AbortSignal, ): Promise> { if (!envVars || Object.keys(envVars).length === 0) { return Result.ok(undefined); } const self = this; return Result.gen(async function* () { const scope = yield* Result.await( self.#resolveEnvironmentVariableScope(projectId, branchId, signal), ); for (const [key, value] of Object.entries(envVars)) { yield* self.#checkAborted(signal); const existing = yield* Result.await( self.#api.listEnvironmentVariables({ ...scope, key }, signal), ); const current = existing[0]; if (value === null) { if (current) { yield* self.#checkAborted(signal); // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous. yield* Result.await( self.#api.deleteEnvironmentVariable(current.id), ); } continue; } if (current) { yield* self.#checkAborted(signal); // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous. yield* Result.await( self.#api.updateEnvironmentVariable(current.id, { value }), ); continue; } yield* self.#checkAborted(signal); // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous. yield* Result.await( self.#api.createEnvironmentVariable({ ...scope, key, value }), ); } return Result.ok(undefined); }); } async #resolveEnvironmentVariableScope( projectId: string, branchId: string | null, signal?: AbortSignal, ): Promise< Result< | { projectId: string; class: "production" } | { projectId: string; class: "preview"; branchId: string; }, ApiRequestError > > { if (!branchId) { return Result.ok({ projectId, class: "production" }); } const branch = await this.#api.getBranch(branchId, signal); if (branch.isErr()) return branch; if (branch.value.role === "preview") { return Result.ok({ projectId, class: "preview", branchId }); } return Result.ok({ projectId, class: "production" }); } async #uploadArtifactResult( url: string, body: Uint8Array, signal?: AbortSignal, ): Promise> { return Result.tryPromise({ try: async () => { await uploadArtifact(url, body, signal); }, catch: (e) => { if (signal?.aborted) return new CancelledError(); return new ArtifactError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }); } } /** * Sort deployments: running/provisioning first, then by createdAt descending. */ function deploymentSortComparator( a: DeploymentInfo, b: DeploymentInfo, ): number { const statusPriority = (s: string) => s === "running" ? 0 : s === "provisioning" ? 1 : 2; const aPriority = statusPriority(a.status); const bPriority = statusPriority(b.status); if (aPriority !== bPriority) return aPriority - bPriority; return b.createdAt.localeCompare(a.createdAt); }