1import * as Prisma from "@/Prisma";
2import * as Test from "@/Test/Alchemy";
3import { getProject, getService } from "@distilled.cloud/prisma/management";
4import { expect } from "alchemy-test";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14
15const { test } = Test.make({ providers: Prisma.providers() });
16
17const wantsLive = process.env.ALCHEMY_RUN_LIVE_PRISMA_TESTS === "true";
18const hasLiveCredentials =
19 process.env.ALCHEMY_RUN_LIVE_PRISMA_WITH_PROFILE === "true";
20const runLive = wantsLive && hasLiveCredentials;
21const wantsCleanup =
22 process.env.ALCHEMY_RUN_LIVE_PRISMA_CLEANUP === "true" &&
23 !!process.env.PRISMA_CLEANUP_PROJECT_ID?.trim();
24const runCleanup = wantsCleanup && hasLiveCredentials;
25
26const logLevel = Effect.provideService(
27 MinimumLogLevel,
28 process.env.DEBUG ? "Debug" : "Info",
29);
30
31if (wantsLive && !hasLiveCredentials) {
32 test(
33 "requires Prisma credentials for the live Compute smoke",
34 Effect.fail(
35 new Error(
36 [
37 "Live Prisma Compute smoke requested but no credentials are configured.",
38 "Run `alchemy profile edit --re-configure Prisma` and select `Service Token`,",
39 "then rerun this live test with ALCHEMY_RUN_LIVE_PRISMA_TESTS=true.",
40 ].join(" "),
41 ),
42 ),
43 { tags: ["provider:prisma", "provider:prisma:compute", "live"] },
44 );
45}
46
47if (wantsCleanup && !hasLiveCredentials) {
48 test(
49 "requires Prisma credentials for existing Compute cleanup",
50 Effect.fail(
51 new Error(
52 [
53 "Live Prisma Compute cleanup requested but no credentials are configured.",
54 "Run `alchemy profile edit --re-configure Prisma` and select `Service Token`.",
55 ].join(" "),
56 ),
57 ),
58 { tags: ["provider:prisma", "provider:prisma:compute", "live"] },
59 );
60}
61
62test.provider.skipIf(!runCleanup)(
63 "live cleans up an existing Prisma Compute project/App from configured credentials",
64 () =>
65 Effect.gen(function* () {
66 const projectId = process.env.PRISMA_CLEANUP_PROJECT_ID!.trim();
67 const appId = process.env.PRISMA_CLEANUP_APP_ID?.trim() || undefined;
68 const deploymentId =
69 process.env.PRISMA_CLEANUP_DEPLOYMENT_ID?.trim() || undefined;
70
71 if (deploymentId) {
72 yield* Prisma.destroyDeployment(deploymentId, {
73 timeoutSeconds: 240,
74 });
75 }
76 yield* Prisma.destroyProjectApps(projectId, {
77 timeoutSeconds: 240,
78 });
79
80 yield* expectGone("Prisma project", Prisma.getProject(projectId));
81 if (appId) {
82 yield* expectGone("Prisma App", Prisma.getApp(appId));
83 }
84 }).pipe(logLevel),
85 {
86 tags: ["provider:prisma", "provider:prisma:compute", "live"],
87 timeout: 600_000,
88 },
89);
90
91test.provider.skipIf(!runLive)(
92 "live deploys, reaches, and destroys a Prisma Compute app",
93 (stack) =>
94 Effect.gen(function* () {
95 const fs = yield* FileSystem.FileSystem;
96 const path = yield* Path.Path;
97 const appDir = yield* fs.makeTempDirectory({
98 prefix: "alchemy-prisma-compute-",
99 });
100 yield* fs.writeFileString(
101 path.join(appDir, "package.json"),
102 ["{", ' "type": "module",', ' "main": "server.ts"', "}", ""].join(
103 "\n",
104 ),
105 );
106 yield* fs.writeFileString(
107 path.join(appDir, "server.ts"),
108 [
109 'const port = Number(process.env["PORT"] ?? "8080");',
110 "const server = Bun.serve({",
111 " port,",
112 " routes: {",
113 ' "/": new Response(process.env["GREETING"] ?? "missing"),',
114 ' "/health": Response.json({ ok: true }),',
115 " },",
116 "});",
117 "console.log(`Listening on ${server.url}`);",
118 "export {};",
119 "",
120 ].join("\n"),
121 );
122
123 const suffix = yield* Effect.sync(() => Date.now().toString(36));
124 const name = `alchemy-compute-${suffix}`;
125
126 yield* stack.destroy();
127
128 let deployed:
129 | {
130 projectId: string;
131 appId: string;
132 deploymentId: string;
133 }
134 | undefined;
135
136 yield* Effect.gen(function* () {
137 const output = yield* stack.deploy(
138 Effect.gen(function* () {
139 const project = yield* Prisma.Project("Project", {
140 name,
141 createDatabase: false,
142 });
143 const app = yield* Prisma.Compute("App", {
144 project: project.projectId,
145 appName: name,
146 path: appDir,
147 entrypoint: "server.ts",
148 port: 8080,
149 env: {
150 GREETING: "hello from alchemy",
151 },
152 timeoutSeconds: 240,
153 destroyOldDeployment: true,
154 });
155 return { project, app };
156 }),
157 );
158
159 expect(output.project.projectId).toBeDefined();
160 expect(output.app.appId).toBeDefined();
161 expect(output.app.deploymentId).toBeDefined();
162 expect(output.app.url).toBeDefined();
163 deployed = {
164 projectId: output.project.projectId,
165 appId: output.app.appId,
166 deploymentId: output.app.deploymentId!,
167 };
168
169 const text = yield* fetchText(`${output.app.url}/`);
170 expect(text).toBe("hello from alchemy");
171 yield* stack
172 .destroy()
173 .pipe(
174 Effect.mapError(
175 (error) =>
176 new Error(
177 [
178 "Prisma Compute live smoke deployed successfully but destroy failed.",
179 `projectId=${deployed?.projectId}`,
180 `appId=${deployed?.appId}`,
181 `deploymentId=${deployed?.deploymentId}`,
182 deployed
183 ? [
184 "Retry cleanup after the platform fix with:",
185 "ALCHEMY_RUN_LIVE_PRISMA_WITH_PROFILE=true \\",
186 `PRISMA_CLEANUP_PROJECT_ID=${deployed.projectId} \\`,
187 `PRISMA_CLEANUP_APP_ID=${deployed.appId} \\`,
188 `PRISMA_CLEANUP_DEPLOYMENT_ID=${deployed.deploymentId} \\`,
189 "ALCHEMY_RUN_LIVE_PRISMA_CLEANUP=true bun vitest run packages/alchemy/test/Prisma/Compute.live.test.ts",
190 ].join(" ")
191 : undefined,
192 ]
193 .filter((line): line is string => line !== undefined)
194 .join(" "),
195 { cause: error },
196 ),
197 ),
198 );
199 yield* expectGone(
200 "Prisma project",
201 Prisma.getProject(deployed.projectId),
202 );
203 yield* expectGone("Prisma App", Prisma.getApp(deployed.appId));
204 }).pipe(
205 Effect.ensuring(
206 Effect.gen(function* () {
207 yield* stack.destroy().pipe(Effect.ignore);
208 yield* fs.remove(appDir, { recursive: true }).pipe(Effect.ignore);
209 }),
210 ),
211 );
212 }).pipe(logLevel),
213 {
214 tags: [
215 "provider:prisma",
216 "provider:prisma:compute",
217 "provider:prisma:project",
218 "live",
219 ],
220 timeout: 600_000,
221 },
222);
223
224test.provider.skipIf(!runLive)(
225 "live deploys, serves, and destroys a Prisma static site",
226 (stack) =>
227 Effect.gen(function* () {
228 const fs = yield* FileSystem.FileSystem;
229 const path = yield* Path.Path;
230 const fixture = yield* path.fromFileUrl(
231 new URL("./fixtures/compute-static-live/", import.meta.url),
232 );
233 const nodeModules = yield* path.fromFileUrl(
234 new URL("../../node_modules/", import.meta.url),
235 );
236 const appDir = yield* fs.makeTempDirectoryScoped({
237 prefix: "alchemy-prisma-static-site-",
238 });
239 yield* fs.copy(fixture, appDir);
240 yield* fs.symlink(nodeModules, path.join(appDir, "node_modules"));
241
242 const deploy = (version: string) =>
243 stack.deploy(
244 Effect.gen(function* () {
245 const project = yield* Prisma.Project("Project", {
246 createDatabase: false,
247 });
248 const site = yield* Prisma.Compute("Site", {
249 project: project.projectId,
250 path: appDir,
251 build: {
252 type: "auto",
253 env: { VITE_SITE_VERSION: version },
254 timeoutSeconds: 30,
255 },
256 healthCheck: { path: "/" },
257 timeoutSeconds: 45,
258 destroyOldDeployment: true,
259 });
260 return { project, site };
261 }),
262 );
263
264 const verifySite = Effect.fn(function* (url: string, version: string) {
265 const origin = url.replace(/\/$/, "");
266 const index = yield* fs.readFileString(
267 path.join(appDir, "dist/index.html"),
268 );
269 const script = yield* fs.readFileString(
270 path.join(appDir, "dist/assets/app.js"),
271 );
272 const scriptStat = yield* fs.stat(
273 path.join(appDir, "dist/assets/app.js"),
274 );
275 const docs = yield* fs.readFileString(
276 path.join(appDir, "dist/docs/index.html"),
277 );
278 const docsStyle = yield* fs.readFileString(
279 path.join(appDir, "dist/docs/style.css"),
280 );
281 expect(index).toContain("alchemy static shell");
282 expect(index).toContain(`<p id="version">${version}</p>`);
283 expect(script).toContain("alchemy static asset");
284
285 yield* Effect.gen(function* () {
286 const response = yield* HttpClient.get(`${origin}/`);
287 const text = yield* response.text;
288 if (response.status !== 200 || text !== index) {
289 return yield* Effect.fail(
290 new Error(
291 `Static site ${version} is not ready (HTTP ${response.status})`,
292 ),
293 );
294 }
295 }).pipe(
296 Effect.timeout("3 seconds"),
297 Effect.retry({ schedule: Schedule.spaced("500 millis"), times: 8 }),
298 Effect.timeout("15 seconds"),
299 );
300
301 const asset = yield* HttpClient.get(`${origin}/assets/app.js`);
302 expect(asset.status).toBe(200);
303 expect(asset.headers["content-type"]).toContain("javascript");
304 expect(yield* asset.text).toBe(script);
305 const head = yield* HttpClient.head(`${origin}/assets/app.js`);
306 expect(head.status).toBe(200);
307 expect(head.headers["content-length"]).toBe(String(scriptStat.size));
308 expect(yield* head.text).toBe("");
309 const route = yield* HttpClient.get(`${origin}/client/route`);
310 expect(route.status).toBe(200);
311 expect(yield* route.text).toBe(index);
312
313 const query = "?from=%2Fclient%2Froute&next=%2F%2Fevil.example";
314 for (const pathname of ["/docs", "//docs"]) {
315 const redirect = yield* HttpClient.get(
316 `${origin}${pathname}${query}`,
317 );
318 expect(redirect.status).toBe(301);
319 expect(redirect.headers["location"]).toBe(`/docs/${query}`);
320 const target = yield* Effect.sync(
321 () => new URL(redirect.headers["location"]!, origin),
322 );
323 expect(target.origin).toBe(origin);
324 expect(target.search).toBe(query);
325 expect(decodeURIComponent(target.pathname).replace(/^\/+/, "/")).toBe(
326 "/docs/",
327 );
328 yield* redirect.text;
329 const directory = yield* HttpClient.get(target.href);
330 expect(directory.status).toBe(200);
331 expect(yield* directory.text).toBe(docs);
332 const relativeAsset = yield* Effect.sync(
333 () => new URL("./style.css", target).href,
334 );
335 const stylesheet = yield* HttpClient.get(relativeAsset);
336 expect(stylesheet.status).toBe(200);
337 expect(stylesheet.headers["content-type"]).toContain("text/css");
338 expect(yield* stylesheet.text).toBe(docsStyle);
339 }
340 });
341
342 yield* stack.destroy();
343 yield* Effect.gen(function* () {
344 const output = yield* deploy("v1");
345 expect(output.site.url).toBeDefined();
346 expect(output.site.deploymentId).toBeDefined();
347 expect(
348 (yield* getProject({ id: output.project.projectId })).data.id,
349 ).toBe(output.project.projectId);
350 expect(
351 (yield* getService({ serviceId: output.site.appId })).data.id,
352 ).toBe(output.site.appId);
353 yield* verifySite(output.site.url!, "v1").pipe(
354 Effect.timeout("20 seconds"),
355 Effect.provideService(FetchHttpClient.RequestInit, {
356 redirect: "manual",
357 cache: "no-store",
358 }),
359 Effect.provide(FetchHttpClient.layer),
360 );
361
362 const updated = yield* deploy("v2");
363 expect(updated.project.projectId).toBe(output.project.projectId);
364 expect(updated.site.appId).toBe(output.site.appId);
365 expect(updated.site.url).toBe(output.site.url);
366 expect(updated.site.deploymentId).toBeDefined();
367 expect(updated.site.deploymentId).not.toBe(output.site.deploymentId);
368 expect(
369 (yield* getService({ serviceId: updated.site.appId })).data.id,
370 ).toBe(updated.site.appId);
371 yield* verifySite(updated.site.url!, "v2").pipe(
372 Effect.timeout("20 seconds"),
373 Effect.provideService(FetchHttpClient.RequestInit, {
374 redirect: "manual",
375 cache: "no-store",
376 }),
377 Effect.provide(FetchHttpClient.layer),
378 );
379
380 yield* stack.destroy();
381 const projectGone = yield* getProject({
382 id: output.project.projectId,
383 }).pipe(
384 Effect.as(false),
385 Effect.catchTag("NotFound", () => Effect.succeed(true)),
386 Effect.repeat({
387 until: (gone) => gone,
388 schedule: Schedule.spaced("500 millis"),
389 times: 8,
390 }),
391 Effect.timeout("10 seconds"),
392 );
393 const appGone = yield* getService({
394 serviceId: output.site.appId,
395 }).pipe(
396 Effect.as(false),
397 Effect.catchTag("NotFound", () => Effect.succeed(true)),
398 Effect.repeat({
399 until: (gone) => gone,
400 schedule: Schedule.spaced("500 millis"),
401 times: 8,
402 }),
403 Effect.timeout("10 seconds"),
404 );
405 expect(projectGone).toBe(true);
406 expect(appGone).toBe(true);
407 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.ignore)));
408 }).pipe(logLevel),
409 {
410 tags: [
411 "provider:prisma",
412 "provider:prisma:compute",
413 "provider:prisma:project",
414 "live",
415 ],
416 timeout: 120_000,
417 },
418);
419
420test.provider.skipIf(!runLive)(
421 "live rolls a Prisma App back to an existing deployment",
422 (stack) =>
423 Effect.gen(function* () {
424 const fs = yield* FileSystem.FileSystem;
425 const path = yield* Path.Path;
426 const appDir = yield* fs.makeTempDirectory({
427 prefix: "alchemy-prisma-rollback-",
428 });
429 yield* fs.writeFileString(
430 path.join(appDir, "package.json"),
431 ["{", ' "type": "module",', ' "main": "server.ts"', "}", ""].join(
432 "\n",
433 ),
434 );
435 yield* fs.writeFileString(
436 path.join(appDir, "server.ts"),
437 [
438 'const port = Number(process.env["PORT"] ?? "8080");',
439 "const server = Bun.serve({",
440 " port,",
441 " routes: {",
442 ' "/": new Response(process.env["GREETING"] ?? "missing"),',
443 ' "/health": Response.json({ ok: true }),',
444 " },",
445 "});",
446 "console.log(`Listening on ${server.url}`);",
447 "export {};",
448 "",
449 ].join("\n"),
450 );
451
452 const suffix = yield* Effect.sync(() => Date.now().toString(36));
453 const name = `alchemy-rollback-${suffix}`;
454
455 yield* stack.destroy();
456
457 yield* Effect.gen(function* () {
458 const output = yield* stack.deploy(
459 Effect.gen(function* () {
460 const project = yield* Prisma.Project("Project", {
461 name,
462 createDatabase: false,
463 });
464 const app = yield* Prisma.Compute("App", {
465 project: project.projectId,
466 appName: name,
467 path: appDir,
468 entrypoint: "server.ts",
469 port: 8080,
470 env: { GREETING: "rollback target" },
471 timeoutSeconds: 240,
472 destroyOldDeployment: false,
473 });
474 return { project, app };
475 }),
476 );
477
478 const appId = output.app.appId;
479 const deploymentId = output.app.deploymentId;
480 expect(appId).toBeDefined();
481 expect(deploymentId).toBeDefined();
482
483 // Roll the App back to its currently-live deployment. The control
484 // plane accepts a roll to the live deployment (roll-forward / drift
485 // heal) and returns the stable App endpoint plus the number of custom
486 // domains reassigned (0 here, since none are attached). This drives
487 // the canonical POST /v1/apps/{id}/rollback contract.
488 const result = yield* Prisma.rollbackApp(appId, {
489 deploymentId: deploymentId!,
490 });
491 expect(typeof result.appEndpointDomain).toBe("string");
492 expect(result.appEndpointDomain.length).toBeGreaterThan(0);
493 expect(result.reassignedDomains).toBe(0);
494
495 const text = yield* fetchText(`${output.app.url}/`);
496 expect(text).toBe("rollback target");
497
498 yield* stack.destroy();
499 const projectGone = yield* getProject({
500 id: output.project.projectId,
501 }).pipe(
502 Effect.as(false),
503 Effect.catchTag("NotFound", () => Effect.succeed(true)),
504 );
505 const appGone = yield* getService({ serviceId: appId }).pipe(
506 Effect.as(false),
507 Effect.catchTag("NotFound", () => Effect.succeed(true)),
508 );
509 expect(projectGone).toBe(true);
510 expect(appGone).toBe(true);
511 }).pipe(
512 Effect.ensuring(
513 Effect.gen(function* () {
514 yield* stack.destroy().pipe(Effect.ignore);
515 yield* fs.remove(appDir, { recursive: true }).pipe(Effect.ignore);
516 }),
517 ),
518 );
519 }).pipe(logLevel),
520 {
521 tags: [
522 "provider:prisma",
523 "provider:prisma:compute",
524 "provider:prisma:project",
525 "live",
526 ],
527 timeout: 600_000,
528 },
529);
530
531const fetchText = (url: string) =>
532 Effect.gen(function* () {
533 const http = yield* HttpClient.HttpClient;
534 const response = yield* http.execute(HttpClientRequest.get(url));
535 if (response.status < 200 || response.status >= 300) {
536 return yield* Effect.fail(
537 new Error(`Prisma Compute app returned HTTP ${response.status}`),
538 );
539 }
540 return yield* response.text;
541 }).pipe(
542 Effect.retry({
543 schedule: Schedule.max([
544 Schedule.exponential(Duration.seconds(1)),
545 Schedule.recurs(8),
546 ]),
547 }),
548 );
549
550const expectGone = <A, E, R>(label: string, effect: Effect.Effect<A, E, R>) =>
551 Effect.gen(function* () {
552 const gone = yield* effect.pipe(
553 Effect.as(false),
554 Effect.catchIf(Prisma.isNotFound, () => Effect.succeed(true)),
555 );
556 if (!gone) return yield* Effect.fail(new Error(`${label} still exists`));
557 }).pipe(
558 Effect.retry({
559 schedule: Schedule.max([
560 Schedule.exponential(Duration.seconds(1)),
561 Schedule.recurs(8),
562 ]),
563 }),
564 );