1import * as Data from "effect/Data";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Category from "@distilled.cloud/core/category";
6import {
7 type GetServicesResponse,
8 deleteService,
9 deleteDeployment,
10 deleteProject,
11 getServices,
12} from "@distilled.cloud/prisma/management";
13import { stopDeploymentIdempotent } from "./Internal/DeploymentActions.ts";
14import { observeDeployment } from "./Internal/DeploymentObserve.ts";
15import type { ObservedDeployment } from "./Internal/Observed.ts";
16import { PrismaPaginationError } from "./Internal/Pagination.ts";
17export { isConflict } from "./Client.ts";
18
19/**
20 * A wait exceeded its `timeoutSeconds` budget before the deployment reached
21 * the requested status.
22 */
23export class PrismaDeploymentWaitTimeout extends Data.TaggedError(
24 "PrismaDeploymentWaitTimeout",
25)<{
26 message: string;
27}> {}
28
29/** `waitForDeploymentStatus` was called with a non-positive timeout or poll interval. */
30export class PrismaDeploymentWaitInvalidOptions extends Data.TaggedError(
31 "PrismaDeploymentWaitInvalidOptions",
32)<{
33 message: string;
34}> {}
35
36/** The deployment reached the terminal `failed` status while being waited on. */
37export class PrismaDeploymentFailed extends Data.TaggedError(
38 "PrismaDeploymentFailed",
39)<{
40 message: string;
41}> {}
42
43export interface WaitForDeploymentStatusOptions {
44 /**
45 * Maximum time to wait for a deployment to reach the requested status.
46 */
47 timeoutSeconds?: number;
48 /**
49 * Poll interval used while waiting for Prisma deployment status changes.
50 */
51 pollIntervalMs?: number;
52}
53
54/**
55 * Result returned after attempting to stop and delete a Prisma deployment.
56 */
57export interface DestroyDeploymentResult {
58 /**
59 * Prisma deployment ID that was targeted.
60 */
61 deploymentId: string;
62 /**
63 * Status observed before cleanup started, or undefined if the deployment was gone.
64 */
65 previousStatus: string | undefined;
66 /**
67 * True when Alchemy requested a stop before deletion.
68 */
69 stopped: boolean;
70 /**
71 * True when the delete call completed or the deployment vanished during cleanup.
72 */
73 deleted: boolean;
74}
75
76/** Result returned after deleting a Prisma App. */
77export interface DestroyAppResult {
78 /** Prisma App ID that was targeted. */
79 appId: string;
80 /** True when App deletion completed or the app was already gone. */
81 appDeleted: boolean;
82}
83
84/**
85 * Result returned after deleting Apps under a Prisma project.
86 */
87export interface DestroyProjectAppsResult {
88 /**
89 * Prisma project ID that was targeted.
90 */
91 projectId: string;
92 /**
93 * App IDs deleted by this cleanup pass.
94 */
95 deletedAppIds: string[];
96 /**
97 * True when the project delete call completed or the project vanished.
98 */
99 projectDeleted: boolean;
100}
101
102const DEFAULT_TIMEOUT_SECONDS = 120;
103const DEFAULT_POLL_INTERVAL_MS = 1_000;
104const DELETE_CONFLICT_RETRY_ATTEMPTS = 5;
105
106const deleteRetryDelay = (attempt: number) =>
107 Effect.sleep(Duration.millis(250 * 2 ** attempt));
108
109const ensureError = (error: unknown): Error =>
110 error instanceof Error
111 ? error
112 : new Error("Prisma deployment lifecycle operation failed.", {
113 cause: error,
114 });
115
116const deploymentDeleteFailed = (
117 deploymentId: string,
118 statusAtDelete: string | undefined,
119 error: unknown,
120) => {
121 const format = (error: unknown) =>
122 error instanceof Error ? error.message : String(error);
123 const detail = format(error);
124 const isKnownStoppedDeleteFailure =
125 statusAtDelete === "stopped" && Category.hasCategory(error, "ServerError");
126 return new Error(
127 [
128 `Failed to delete Prisma deployment '${deploymentId}' while it was in status '${statusAtDelete ?? "unknown"}'.`,
129 detail,
130 isKnownStoppedDeleteFailure
131 ? "Stopped Prisma deployments are expected to be deletable; the Management API returned a server error."
132 : undefined,
133 "The deployment may need platform cleanup before the App or project can be deleted.",
134 `Manual check: GET /v1/deployments/${deploymentId}; manual retry: DELETE /v1/deployments/${deploymentId}.`,
135 ]
136 .filter((line): line is string => line !== undefined)
137 .join(" "),
138 { cause: error },
139 );
140};
141
142const deploymentWaitTimedOut = (
143 deploymentId: string,
144 targetStatus: "running" | "stopped",
145 lastStatus: string | undefined,
146) =>
147 new PrismaDeploymentWaitTimeout({
148 message: `Timed out waiting for Prisma deployment '${deploymentId}' to reach '${targetStatus}' (last status: '${lastStatus ?? "unknown"}')`,
149 });
150
151export const waitForDeploymentStatus = Effect.fn(function* (
152 deploymentId: string,
153 targetStatus: "running" | "stopped",
154 options: WaitForDeploymentStatusOptions = {},
155) {
156 const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
157 const intervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
158 if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
159 return yield* Effect.fail(
160 new PrismaDeploymentWaitInvalidOptions({
161 message: "timeoutSeconds must be a positive finite number.",
162 }),
163 );
164 }
165 if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
166 return yield* Effect.fail(
167 new PrismaDeploymentWaitInvalidOptions({
168 message: "pollIntervalMs must be a positive finite number.",
169 }),
170 );
171 }
172 const timeoutMs = timeoutSeconds * 1_000;
173 const startedAt = yield* Effect.sync(() => Date.now());
174 const deadline = startedAt + timeoutMs;
175 let lastStatus: string | undefined;
176
177 while (true) {
178 const remainingBeforeObservation = yield* Effect.sync(
179 () => deadline - Date.now(),
180 );
181 if (remainingBeforeObservation <= 0) {
182 return yield* Effect.fail(
183 deploymentWaitTimedOut(deploymentId, targetStatus, lastStatus),
184 );
185 }
186 const deploymentOption = yield* observeDeployment(deploymentId).pipe(
187 Effect.timeoutOption(Duration.millis(remainingBeforeObservation)),
188 );
189 if (Option.isNone(deploymentOption)) {
190 return yield* Effect.fail(
191 deploymentWaitTimedOut(deploymentId, targetStatus, lastStatus),
192 );
193 }
194 const deployment = deploymentOption.value;
195 lastStatus = deployment.status;
196 if (deployment.status === targetStatus) {
197 return deployment satisfies ObservedDeployment;
198 }
199 if (deployment.status === "failed") {
200 return yield* Effect.fail(
201 new PrismaDeploymentFailed({
202 message: `Prisma deployment '${deploymentId}' failed`,
203 }),
204 );
205 }
206
207 const elapsed = yield* Effect.sync(() => Date.now() - startedAt);
208 if (elapsed >= timeoutMs) {
209 return yield* Effect.fail(
210 deploymentWaitTimedOut(deploymentId, targetStatus, lastStatus),
211 );
212 }
213
214 yield* Effect.sleep(
215 Duration.millis(Math.min(intervalMs, timeoutMs - elapsed)),
216 );
217 }
218});
219
220/**
221 * Stops a running or provisioning deployment, then deletes it.
222 *
223 * Uses the canonical deployment lifecycle routes. Errors include the observed
224 * status and exact manual route for cleanup.
225 */
226export const destroyDeployment = Effect.fn(function* (
227 deploymentId: string,
228 options: WaitForDeploymentStatusOptions = {},
229) {
230 const deployment = yield* observeDeployment(deploymentId).pipe(
231 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
232 );
233 if (!deployment) {
234 return {
235 deploymentId,
236 previousStatus: undefined,
237 stopped: false,
238 // The postcondition is already satisfied. Report this consistently
239 // with delete calls whose 404 is observed after cleanup starts.
240 deleted: true,
241 } satisfies DestroyDeploymentResult;
242 }
243
244 const previousStatus = deployment.status;
245 let statusAtDelete = previousStatus;
246 let stopped = false;
247 if (deployment.status === "running" || deployment.status === "provisioning") {
248 yield* stopDeploymentIdempotent(deploymentId).pipe(
249 Effect.catchTag("NotFound", () => Effect.void),
250 Effect.mapError(ensureError),
251 );
252 const stoppedVersion = yield* waitForDeploymentStatus(
253 deploymentId,
254 "stopped",
255 options,
256 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
257 statusAtDelete = stoppedVersion?.status ?? "stopped";
258 stopped = true;
259 }
260
261 yield* deleteDeployment({ deploymentId }).pipe(
262 Effect.catchTag("NotFound", () => Effect.void),
263 Effect.catch((error) =>
264 Effect.fail(deploymentDeleteFailed(deploymentId, statusAtDelete, error)),
265 ),
266 );
267
268 return {
269 deploymentId,
270 previousStatus,
271 stopped,
272 deleted: true,
273 } satisfies DestroyDeploymentResult;
274});
275
276/**
277 * Deletes an App. The canonical App delete endpoint cascades its deployments.
278 */
279export const destroyApp = Effect.fn(function* (
280 appId: string,
281 options: WaitForDeploymentStatusOptions & {
282 keepApp?: boolean;
283 } = {},
284) {
285 let appDeleted = false;
286 if (!options.keepApp) {
287 for (let attempt = 0; attempt < DELETE_CONFLICT_RETRY_ATTEMPTS; attempt++) {
288 const deleted = yield* deleteService({ serviceId: appId }).pipe(
289 Effect.as(true),
290 Effect.catchTag("NotFound", () => Effect.succeed(true)),
291 Effect.catchTag("Conflict", (error) =>
292 attempt + 1 < DELETE_CONFLICT_RETRY_ATTEMPTS
293 ? deleteRetryDelay(attempt).pipe(Effect.as(false))
294 : Effect.fail(error),
295 ),
296 );
297 if (deleted) {
298 appDeleted = true;
299 break;
300 }
301 }
302 }
303
304 return {
305 appId,
306 appDeleted,
307 } satisfies DestroyAppResult;
308});
309
310/**
311 * Deletes every App under a project, then deletes the project.
312 *
313 * Callers can start from the project ID and let Alchemy discover the App IDs.
314 */
315export const destroyProjectApps = Effect.fn(function* (
316 projectId: string,
317 options: WaitForDeploymentStatusOptions & {
318 keepProject?: boolean;
319 keepApp?: boolean;
320 } = {},
321) {
322 const deletedAppIds = new Set<string>();
323
324 const cleanupApps = Effect.fn(function* () {
325 // Distilled emits the cursor-paginated list operations as plain ops, so
326 // callers walk `pagination` themselves (see `src/Neon/Project.ts`). A 404
327 // from the project-filtered listing means the project is already gone.
328 const apps = yield* Effect.gen(function* () {
329 const items: GetServicesResponse["data"][number][] = [];
330 let cursor: string | undefined;
331 while (true) {
332 const page = yield* getServices(
333 cursor === undefined
334 ? { projectId, limit: 100 }
335 : { projectId, limit: 100, cursor },
336 );
337 items.push(...page.data);
338 const nextCursor = page.pagination.nextCursor;
339 if (!page.pagination.hasMore) break;
340 if (nextCursor === null) {
341 return yield* Effect.fail(
342 new PrismaPaginationError({
343 message:
344 "Invalid Prisma Management API pagination response from getServices: hasMore was true without a non-empty nextCursor",
345 }),
346 );
347 }
348 cursor = nextCursor;
349 }
350 return items;
351 }).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
352 if (!apps) return false;
353 for (const app of apps) {
354 const result = yield* destroyApp(app.id, options);
355 if (result.appDeleted) deletedAppIds.add(app.id);
356 }
357 return true;
358 });
359
360 yield* cleanupApps();
361
362 let projectDeleted = false;
363 if (!options.keepProject) {
364 for (let attempt = 0; attempt < DELETE_CONFLICT_RETRY_ATTEMPTS; attempt++) {
365 // A 409, or the API's 400 on a still-populated project, means the
366 // delete is blocked on remaining member resources: re-clean and retry.
367 const deleted = yield* deleteProject({ id: projectId }).pipe(
368 Effect.as(true),
369 Effect.catchTag("NotFound", () => Effect.succeed(true)),
370 Effect.catchTag("Conflict", (error) =>
371 Effect.gen(function* () {
372 if (attempt + 1 >= DELETE_CONFLICT_RETRY_ATTEMPTS) {
373 return yield* Effect.fail(error);
374 }
375 yield* cleanupApps();
376 yield* deleteRetryDelay(attempt);
377 return false;
378 }),
379 ),
380 Effect.catchTag("BadRequest", (error) =>
381 Effect.gen(function* () {
382 if (attempt + 1 >= DELETE_CONFLICT_RETRY_ATTEMPTS) {
383 return yield* Effect.fail(error);
384 }
385 yield* cleanupApps();
386 yield* deleteRetryDelay(attempt);
387 return false;
388 }),
389 ),
390 );
391 if (deleted) {
392 projectDeleted = true;
393 break;
394 }
395 }
396 }
397
398 return {
399 projectId,
400 deletedAppIds: Array.from(deletedAppIds),
401 projectDeleted,
402 } satisfies DestroyProjectAppsResult;
403});
404
405export const toDeploymentUrl = (domain: string | null | undefined) =>
406 domain
407 ? domain.startsWith("http://") || domain.startsWith("https://")
408 ? domain
409 : `https://${domain}`
410 : undefined;