1import { PrismaApiError, type PrismaManagementClient } from "@/Prisma/Client";
2import {
3 destroyApp,
4 destroyDeployment,
5 destroyProjectApps,
6 waitForDeploymentStatus,
7} from "@/Prisma/ComputeLifecycle";
8import { describe, expect, it } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as TestClock from "effect/testing/TestClock";
12import * as Layer from "effect/Layer";
13import * as HttpClient from "effect/http/HttpClient";
14import { Retry, fromApiToken } from "@distilled.cloud/prisma";
15import {
16 type Captured,
17 data,
18 dispatchTo,
19 FAKE_API_BASE_URL,
20 makeFakeManagementApi,
21 page,
22 unhandled,
23} from "./fixtures/FakeManagementApi.ts";
24import { PrismaPaginationError } from "@/Prisma/Internal/Pagination";
25
26const apiError = (
27 method: "GET" | "POST" | "DELETE",
28 path: string,
29 status: number,
30) => new PrismaApiError({ method, path, status, message: `HTTP ${status}` });
31
32const deployment = (id: string, status: string) => ({
33 id,
34 type: "deployment" as const,
35 serviceId: "app-1",
36 url: `https://api.prisma.test/v1/deployments/${id}`,
37 foundryVersionId: `foundry-${id}`,
38 status,
39 previewDomain: `${id}.preview.prisma.build`,
40 createdAt: "2026-01-01T00:00:00Z",
41});
42
43const appItem = (id: string) => ({
44 id,
45 type: "app" as const,
46 url: `https://api.prisma.test/v1/services/${id}`,
47 name: id,
48 region: { id: "us-east-1", name: "US East" },
49 projectId: "project-1",
50 branchId: null,
51 latestDeploymentId: null,
52 appEndpointDomain: `${id}.prisma.build`,
53 createdAt: "2026-01-01T00:00:00Z",
54});
55
56/**
57 * Serve the Management API from the same hermetic client-shaped handlers
58 * these tests declare, for the lifecycle routes now reached through distilled
59 * operations. `dispatchTo` maps each handler's result onto the wire (see the
60 * fixture).
61 */
62const clientBackedApi = (client: any) =>
63 makeFakeManagementApi((request: Captured) => {
64 // segments[0] is the "v1" prefix.
65 const [head, id, tail] = request.pathname
66 .split("/")
67 .filter((segment) => segment.length > 0)
68 .slice(1);
69 const query = Object.fromEntries(new URLSearchParams(request.search));
70 const { call, callVoid, list } = dispatchTo(request);
71
72 if (head === "deployments" && id !== undefined) {
73 if (tail === "stop") return callVoid(client.stopDeployment, [id]);
74 if (request.method === "GET") return call(client.getDeployment, [id]);
75 if (request.method === "DELETE") {
76 return callVoid(client.deleteDeployment, [id]);
77 }
78 }
79 if (head === "services") {
80 if (id === undefined && request.method === "GET") {
81 return call(client.listApps, [query], list);
82 }
83 if (id !== undefined && tail === "deployments") {
84 return call(client.listAppDeployments, [id, query], list);
85 }
86 if (id !== undefined && request.method === "DELETE") {
87 return callVoid(client.deleteApp, [id]);
88 }
89 }
90 if (
91 head === "projects" &&
92 id !== undefined &&
93 request.method === "DELETE"
94 ) {
95 return callVoid(client.deleteProject, [id]);
96 }
97 return unhandled(request);
98 });
99
100const provide = (client: unknown) =>
101 Effect.provide(clientBackedApi(client).layer);
102
103describe(
104 "Prisma canonical Compute lifecycle",
105 { tags: ["unit", "provider:prisma", "provider:prisma:compute", "local"] },
106 () => {
107 it.live("waits for a deployment to reach its target status", () => {
108 let observed = 0;
109 const client = {
110 getDeployment: (id: string) =>
111 Effect.sync(() =>
112 deployment(id, observed++ === 0 ? "provisioning" : "running"),
113 ),
114 } as unknown as PrismaManagementClient;
115
116 return Effect.gen(function* () {
117 const result = yield* waitForDeploymentStatus(
118 "deployment-1",
119 "running",
120 {
121 pollIntervalMs: 1,
122 timeoutSeconds: 10,
123 },
124 );
125 expect(result.status).toBe("running");
126 expect(observed).toBe(2);
127 }).pipe(provide(client));
128 });
129
130 it.effect("fails immediately for a failed deployment", () => {
131 const client = {
132 getDeployment: (id: string) => Effect.succeed(deployment(id, "failed")),
133 } as unknown as PrismaManagementClient;
134
135 return Effect.gen(function* () {
136 const error = yield* waitForDeploymentStatus(
137 "deployment-1",
138 "running",
139 ).pipe(Effect.flip);
140 expect(error.message).toContain("deployment-1");
141 expect(error.message).toContain("failed");
142 }).pipe(provide(client));
143 });
144
145 it.live("times out with the last observed deployment status", () => {
146 const client = {
147 getDeployment: (id: string) =>
148 Effect.succeed(deployment(id, "provisioning")),
149 } as unknown as PrismaManagementClient;
150
151 return Effect.gen(function* () {
152 const error = yield* waitForDeploymentStatus(
153 "deployment-1",
154 "running",
155 {
156 timeoutSeconds: 0.01,
157 pollIntervalMs: 1,
158 },
159 ).pipe(Effect.flip);
160 expect(error.message).toContain("Timed out");
161 expect(error.message).toContain("provisioning");
162 }).pipe(provide(client));
163 });
164
165 it.live(
166 "caps a hung deployment observation at the polling deadline",
167 () => {
168 // A handler cannot hang inside the synchronous dispatch fake, so the hang
169 // is modeled at the transport: an HTTP client that never answers.
170 const hungTransport = Layer.mergeAll(
171 Layer.succeed(
172 HttpClient.HttpClient,
173 HttpClient.make(() => Effect.never),
174 ),
175 fromApiToken({
176 apiToken: "fake-service-token",
177 apiBaseUrl: FAKE_API_BASE_URL,
178 }),
179 );
180
181 return Effect.gen(function* () {
182 const startedAt = Date.now();
183 const error = yield* waitForDeploymentStatus(
184 "deployment-hung",
185 "running",
186 { timeoutSeconds: 0.05, pollIntervalMs: 1 },
187 ).pipe(Effect.flip);
188 const elapsed = Date.now() - startedAt;
189
190 expect(error.message).toContain("Timed out");
191 expect(error.message).toContain("last status: 'unknown'");
192 expect(elapsed).toBeLessThan(500);
193 }).pipe(Effect.provide(hungTransport));
194 },
195 );
196
197 it.effect("rejects invalid deployment polling timings", () => {
198 const client = {
199 getDeployment: () => Effect.die("invalid options must fail first"),
200 } as unknown as PrismaManagementClient;
201
202 return Effect.gen(function* () {
203 const timeoutError = yield* waitForDeploymentStatus(
204 "deployment-1",
205 "running",
206 { timeoutSeconds: 0 },
207 ).pipe(Effect.flip);
208 const intervalError = yield* waitForDeploymentStatus(
209 "deployment-1",
210 "running",
211 { pollIntervalMs: Number.NaN },
212 ).pipe(Effect.flip);
213
214 expect(timeoutError.message).toContain("timeoutSeconds");
215 expect(intervalError.message).toContain("pollIntervalMs");
216 }).pipe(provide(client));
217 });
218
219 it.effect("preserves a not-found observation while waiting", () => {
220 const client = {
221 getDeployment: () =>
222 Effect.fail(apiError("GET", "/v1/deployments/deployment-1", 404)),
223 } as unknown as PrismaManagementClient;
224
225 return Effect.gen(function* () {
226 const error = yield* waitForDeploymentStatus(
227 "deployment-1",
228 "running",
229 ).pipe(Effect.flip);
230 // Over the wire the injected 404 decodes into the typed error.
231 expect(error._tag).toBe("NotFound");
232 expect(error.message).toBe("HTTP 404");
233 }).pipe(provide(client));
234 });
235
236 it.effect("treats an already deleted deployment as deleted", () => {
237 const client = {
238 getDeployment: (id: string) =>
239 Effect.fail(apiError("GET", `/v1/deployments/${id}`, 404)),
240 } as unknown as PrismaManagementClient;
241
242 return Effect.gen(function* () {
243 const result = yield* destroyDeployment("deployment-missing");
244 expect(result).toEqual({
245 deploymentId: "deployment-missing",
246 previousStatus: undefined,
247 stopped: false,
248 deleted: true,
249 });
250 }).pipe(provide(client));
251 });
252
253 it.effect("stops a running deployment before deleting it", () => {
254 const calls: string[] = [];
255 let status = "running";
256 const client = {
257 getDeployment: (id: string) =>
258 Effect.sync(() => {
259 calls.push(`get:${id}`);
260 return deployment(id, status);
261 }),
262 stopDeployment: (id: string) =>
263 Effect.sync(() => {
264 calls.push(`stop:${id}`);
265 status = "stopped";
266 }),
267 deleteDeployment: (id: string) =>
268 Effect.sync(() => {
269 calls.push(`delete:${id}`);
270 }),
271 } as unknown as PrismaManagementClient;
272
273 return Effect.gen(function* () {
274 const result = yield* destroyDeployment("deployment-1");
275 expect(result).toMatchObject({
276 deploymentId: "deployment-1",
277 previousStatus: "running",
278 stopped: true,
279 deleted: true,
280 });
281 expect(calls).toEqual([
282 "get:deployment-1",
283 "stop:deployment-1",
284 "get:deployment-1",
285 "delete:deployment-1",
286 ]);
287 }).pipe(provide(client));
288 });
289
290 it.effect("reports only the canonical deployment cleanup route", () => {
291 const client = {
292 getDeployment: () =>
293 Effect.succeed(deployment("deployment-1", "stopped")),
294 deleteDeployment: (id: string) =>
295 Effect.fail(apiError("DELETE", `/v1/deployments/${id}`, 400)),
296 } as unknown as PrismaManagementClient;
297
298 return Effect.gen(function* () {
299 const error = yield* destroyDeployment("deployment-1").pipe(
300 Effect.flip,
301 );
302 expect(error.message).toContain("DELETE /v1/deployments/deployment-1");
303 }).pipe(provide(client));
304 });
305
306 it.effect(
307 "explains a stopped deployment whose delete fails with a server error",
308 () => {
309 const client = {
310 getDeployment: () =>
311 Effect.succeed(deployment("deployment-1", "stopped")),
312 deleteDeployment: (id: string) =>
313 // An unmapped status decodes into the catch-all error, which
314 // carries the ServerError category the diagnostic branch reads.
315 Effect.fail(apiError("DELETE", `/v1/deployments/${id}`, 507)),
316 } as unknown as PrismaManagementClient;
317
318 return Effect.gen(function* () {
319 const error = yield* destroyDeployment("deployment-1").pipe(
320 Effect.flip,
321 );
322 expect(error.message).toContain(
323 "Stopped Prisma deployments are expected to be deletable",
324 );
325 expect(error.message).toContain(
326 "DELETE /v1/deployments/deployment-1",
327 );
328 }).pipe(
329 provide(client),
330 // Server-category errors are replayed by the default policy; this
331 // test examines the terminal diagnostic, so retries are disabled.
332 Effect.provide(Layer.succeed(Retry.Retry, { while: () => false })),
333 );
334 },
335 );
336
337 it.effect(
338 "uses the App delete cascade without enumerating deployments",
339 () => {
340 const calls: string[] = [];
341 const client = {
342 listAppDeployments: () =>
343 Effect.die("must not enumerate deployments"),
344 deleteApp: (id: string) =>
345 Effect.sync(() => {
346 calls.push(`delete-app:${id}`);
347 }),
348 } as unknown as PrismaManagementClient;
349
350 return Effect.gen(function* () {
351 const result = yield* destroyApp("app-1");
352 expect(result).toEqual({ appId: "app-1", appDeleted: true });
353 expect(calls).toEqual(["delete-app:app-1"]);
354 }).pipe(provide(client));
355 },
356 );
357
358 it.live("retries a bounded App deletion conflict", () => {
359 let attempts = 0;
360 const client = {
361 deleteApp: (id: string) =>
362 Effect.suspend(() => {
363 attempts += 1;
364 return attempts < 3
365 ? Effect.fail(apiError("DELETE", `/v1/services/${id}`, 409))
366 : Effect.void;
367 }),
368 } as unknown as PrismaManagementClient;
369
370 return Effect.gen(function* () {
371 const result = yield* destroyApp("app-1");
372 expect(result.appDeleted).toBe(true);
373 expect(attempts).toBe(3);
374 }).pipe(provide(client));
375 });
376
377 it.effect("treats an already deleted App as deleted", () => {
378 const client = {
379 deleteApp: (id: string) =>
380 Effect.fail(apiError("DELETE", `/v1/services/${id}`, 404)),
381 } as unknown as PrismaManagementClient;
382
383 return Effect.gen(function* () {
384 expect(yield* destroyApp("app-missing")).toEqual({
385 appId: "app-missing",
386 appDeleted: true,
387 });
388 }).pipe(provide(client));
389 });
390
391 it.effect("surfaces the final App deletion conflict", () => {
392 let attempts = 0;
393 const conflict = apiError("DELETE", "/v1/services/app-1", 409);
394 const client = {
395 deleteApp: () =>
396 Effect.sync(() => {
397 attempts += 1;
398 }).pipe(Effect.flatMap(() => Effect.fail(conflict))),
399 } as unknown as PrismaManagementClient;
400
401 return Effect.gen(function* () {
402 const fiber = yield* destroyApp("app-1").pipe(
403 Effect.flip,
404 Effect.forkChild,
405 );
406 yield* TestClock.adjust("30 seconds");
407 const error = yield* Fiber.join(fiber);
408 // Over the wire the injected 409 decodes into the typed error.
409 expect(error._tag).toBe("Conflict");
410 expect(error.message).toBe("HTTP 409");
411 expect(attempts).toBe(5);
412 }).pipe(provide(client));
413 });
414
415 it.effect("deletes project Apps before deleting the project", () => {
416 const calls: string[] = [];
417 const client = {
418 listApps: (query: unknown) =>
419 Effect.sync(() => {
420 calls.push(`list:${JSON.stringify(query)}`);
421 return [appItem("app-1"), appItem("app-2")];
422 }),
423 deleteApp: (id: string) =>
424 Effect.sync(() => {
425 calls.push(`delete-app:${id}`);
426 }),
427 deleteProject: (id: string) =>
428 Effect.sync(() => {
429 calls.push(`delete-project:${id}`);
430 }),
431 } as unknown as PrismaManagementClient;
432
433 return Effect.gen(function* () {
434 const result = yield* destroyProjectApps("project-1");
435 expect(result).toEqual({
436 projectId: "project-1",
437 deletedAppIds: ["app-1", "app-2"],
438 projectDeleted: true,
439 });
440 expect(calls).toEqual([
441 // Over the wire, query params arrive as strings.
442 'list:{"limit":"100","projectId":"project-1"}',
443 "delete-app:app-1",
444 "delete-app:app-2",
445 "delete-project:project-1",
446 ]);
447 }).pipe(provide(client));
448 });
449
450 it.effect("treats an already deleted project as deleted", () => {
451 const client = {
452 listApps: () =>
453 Effect.fail(apiError("GET", "/v1/services?projectId=project-1", 404)),
454 deleteProject: (id: string) =>
455 Effect.fail(apiError("DELETE", `/v1/projects/${id}`, 404)),
456 } as unknown as PrismaManagementClient;
457
458 return Effect.gen(function* () {
459 expect(yield* destroyProjectApps("project-1")).toEqual({
460 projectId: "project-1",
461 deletedAppIds: [],
462 projectDeleted: true,
463 });
464 }).pipe(provide(client));
465 });
466
467 it.live("re-cleans Apps after a blocked project deletion", () => {
468 const calls: string[] = [];
469 let lists = 0;
470 let deletes = 0;
471 const client = {
472 listApps: () =>
473 Effect.sync(() => {
474 lists += 1;
475 calls.push(`list:${lists}`);
476 return lists === 1 ? [appItem("app-1")] : [];
477 }),
478 deleteApp: (id: string) =>
479 Effect.sync(() => calls.push(`delete-app:${id}`)),
480 deleteProject: (id: string) =>
481 Effect.suspend(() => {
482 deletes += 1;
483 calls.push(`delete-project:${id}:${deletes}`);
484 return deletes === 1
485 ? Effect.fail(apiError("DELETE", `/v1/projects/${id}`, 409))
486 : Effect.void;
487 }),
488 } as unknown as PrismaManagementClient;
489
490 return Effect.gen(function* () {
491 const result = yield* destroyProjectApps("project-1");
492 expect(result).toEqual({
493 projectId: "project-1",
494 deletedAppIds: ["app-1"],
495 projectDeleted: true,
496 });
497 expect(calls).toEqual([
498 "list:1",
499 "delete-app:app-1",
500 "delete-project:project-1:1",
501 "list:2",
502 "delete-project:project-1:2",
503 ]);
504 }).pipe(provide(client));
505 });
506
507 it.effect("supports keeping the project after deleting its Apps", () => {
508 const calls: string[] = [];
509 const client = {
510 listApps: () => Effect.succeed([appItem("app-1")]),
511 deleteApp: (id: string) =>
512 Effect.sync(() => calls.push(`delete-app:${id}`)),
513 deleteProject: () => Effect.die("must not delete project"),
514 } as unknown as PrismaManagementClient;
515
516 return Effect.gen(function* () {
517 const result = yield* destroyProjectApps("project-1", {
518 keepProject: true,
519 });
520 expect(result.projectDeleted).toBe(false);
521 expect(result.deletedAppIds).toEqual(["app-1"]);
522 expect(calls).toEqual(["delete-app:app-1"]);
523 }).pipe(provide(client));
524 });
525
526 it.effect("cleans every App returned across canonical pagination", () => {
527 const apps = Array.from({ length: 101 }, (_, index) =>
528 appItem(`app-${index}`),
529 );
530 const deleted: string[] = [];
531 const client = {
532 // The fake serves the full set in one page; the inline walk stops when
533 // the pagination envelope reports no more pages.
534 listApps: () => Effect.succeed(apps),
535 deleteApp: (id: string) =>
536 Effect.sync(() => {
537 deleted.push(id);
538 }),
539 } as unknown as PrismaManagementClient;
540
541 return Effect.gen(function* () {
542 const result = yield* destroyProjectApps("project-1", {
543 keepProject: true,
544 });
545 expect(result.deletedAppIds).toHaveLength(101);
546 expect(deleted).toHaveLength(101);
547 }).pipe(provide(client));
548 });
549
550 it.effect("supports keeping Apps during project-scoped inspection", () => {
551 const client = {
552 listApps: () => Effect.succeed([appItem("app-1")]),
553 deleteApp: () => Effect.die("must not delete App"),
554 deleteProject: () => Effect.die("must not delete project"),
555 } as unknown as PrismaManagementClient;
556
557 return Effect.gen(function* () {
558 const result = yield* destroyProjectApps("project-1", {
559 keepApp: true,
560 keepProject: true,
561 });
562 expect(result).toEqual({
563 projectId: "project-1",
564 deletedAppIds: [],
565 projectDeleted: false,
566 });
567 }).pipe(provide(client));
568 });
569
570 it.effect(
571 "refuses a truncated App listing that promises another page",
572 () => {
573 // `hasMore: true` with no cursor is a malformed page. Reading it as the
574 // end of the list would let the cleanup miss live Apps, so the walk fails
575 // loudly instead.
576 const fake = makeFakeManagementApi((request: Captured) =>
577 request.pathname === "/v1/services" && request.method === "GET"
578 ? page([appItem("app-1")], true, null)
579 : unhandled(request),
580 );
581
582 return Effect.gen(function* () {
583 const error = yield* destroyProjectApps("project-1").pipe(
584 Effect.flip,
585 );
586 expect(error).toBeInstanceOf(PrismaPaginationError);
587 expect(error.message).toContain(
588 "hasMore was true without a non-empty nextCursor",
589 );
590 }).pipe(Effect.provide(fake.layer));
591 },
592 );
593 },
594);