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