1import * as Cloudflare from "@/Cloudflare";
2import * as Neon from "@/Neon";
3import * as Prisma from "@/Prisma";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import Stack from "./fixtures/prisma-orm/stack.ts";
13
14const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
15 providers: Layer.mergeAll(
16 Cloudflare.providers(),
17 Neon.providers(),
18 Prisma.providers(),
19 ),
20});
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27const stack = beforeAll(deploy(Stack), { timeout: 120_000 });
28afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
29
30class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
31 status: number;
32 body: string;
33}> {
34 get message() {
35 return `Worker returned ${this.status}: ${this.body}`;
36 }
37}
38
39/** GET with fresh-edge retry: workers.dev serves placeholder errors briefly. */
40const getOk = (url: string) =>
41 Effect.gen(function* () {
42 const client = yield* HttpClient.HttpClient;
43 return yield* client.get(url).pipe(
44 Effect.flatMap((res) =>
45 res.status === 200
46 ? Effect.succeed(res)
47 : res.text.pipe(
48 Effect.flatMap((body) =>
49 Effect.fail(new WorkerNotReady({ status: res.status, body })),
50 ),
51 ),
52 ),
53 Effect.timeout("15 seconds"),
54 Effect.retry({
55 while: (error) =>
56 error._tag === "WorkerNotReady" &&
57 (error.status === 404 || error.status === 503),
58 schedule: Schedule.exponential("500 millis"),
59 times: 6,
60 }),
61 );
62 });
63
64test(
65 "Prisma client round-trips orm queries through Hyperdrive on workerd",
66 Effect.gen(function* () {
67 const { url } = yield* stack;
68
69 const created = yield* getOk(`${url}/widgets/create/gizmo`).pipe(
70 Effect.flatMap((res) => res.json),
71 Effect.map((json) => json as { id: number; name: string }),
72 );
73 expect(created.name).toEqual("gizmo");
74 expect(created.id).toBeGreaterThan(0);
75
76 // Read-after-write in a separate fetch event (separate pool).
77 const fetched = yield* getOk(`${url}/widgets/get/${created.id}`).pipe(
78 Effect.flatMap((res) => res.json),
79 Effect.map((json) => json as { found: boolean; name: string | null }),
80 );
81 expect(fetched).toEqual({ found: true, name: "gizmo" });
82 const prepared = yield* getOk(`${url}/widgets/prepared/${created.id}`).pipe(
83 Effect.flatMap((res) => res.json),
84 );
85 expect(prepared).toEqual({ found: true, name: "gizmo" });
86 const missing = yield* getOk(`${url}/widgets/prepared/0`).pipe(
87 Effect.flatMap((res) => res.json),
88 );
89 expect(missing).toEqual({ found: false, name: null });
90 }).pipe(logLevel),
91 {
92 tags: [
93 "provider:cloudflare",
94 "provider:cloudflare:hyperdrive",
95 "provider:cloudflare:worker",
96 "provider:neon",
97 "provider:neon:branch",
98 "provider:neon:project",
99 "provider:prisma",
100 "provider:prisma:orm",
101 "live",
102 ],
103 timeout: 120_000,
104 },
105);
106
107test(
108 "transactions work inside a single event",
109 Effect.gen(function* () {
110 const { url } = yield* stack;
111 const result = yield* getOk(`${url}/widgets/tx/tx-gizmo`).pipe(
112 Effect.flatMap((res) => res.json),
113 Effect.map((json) => json as { name: string | null }),
114 );
115 expect(result.name).toEqual("tx-gizmo");
116 }).pipe(logLevel),
117 {
118 tags: [
119 "provider:cloudflare",
120 "provider:cloudflare:hyperdrive",
121 "provider:cloudflare:worker",
122 "provider:neon",
123 "provider:neon:branch",
124 "provider:neon:project",
125 "provider:prisma",
126 "provider:prisma:orm",
127 "live",
128 ],
129 timeout: 120_000,
130 },
131);
132
133test(
134 "sql lane and typed rollback on workerd",
135 Effect.gen(function* () {
136 const { url } = yield* stack;
137
138 const sql = yield* getOk(`${url}/widgets/sql`).pipe(
139 Effect.flatMap((res) => res.json),
140 Effect.map((json) => json as { count: number }),
141 );
142 expect(sql.count).toBeGreaterThanOrEqual(0);
143
144 const rollback = yield* getOk(`${url}/widgets/rollback/ghost-widget`).pipe(
145 Effect.flatMap((res) => res.json),
146 Effect.map((json) => json as { outcome: string; visible: boolean }),
147 );
148 expect(rollback).toEqual({ outcome: "rolled-back", visible: false });
149 }).pipe(logLevel),
150 {
151 tags: [
152 "provider:cloudflare",
153 "provider:cloudflare:hyperdrive",
154 "provider:cloudflare:worker",
155 "provider:neon",
156 "provider:neon:branch",
157 "provider:neon:project",
158 "provider:prisma",
159 "provider:prisma:orm",
160 "live",
161 ],
162 timeout: 120_000,
163 },
164);
165
166test(
167 "sequential and concurrent events each get their own pool (no cross-request I/O)",
168 Effect.gen(function* () {
169 const { url } = yield* stack;
170
171 const created = yield* getOk(`${url}/widgets/create/pool-widget`).pipe(
172 Effect.flatMap((res) => res.json),
173 Effect.map((json) => json as { id: number }),
174 );
175
176 const query = getOk(`${url}/widgets/get/${created.id}`).pipe(
177 Effect.flatMap((res) => res.json),
178 Effect.map((json) => json as { found: boolean }),
179 );
180
181 // Sequential events reuse nothing across requests.
182 for (let i = 0; i < 3; i++) {
183 expect((yield* query).found).toBe(true);
184 }
185
186 // Concurrent events: workerd kills cross-request I/O ("Cannot perform
187 // I/O on behalf of a different request") and a shared closed pool fails
188 // with "Cannot use a pool after calling end" — six parallel queries pin
189 // the per-event pool isolation.
190 const results = yield* Effect.all(
191 Array.from({ length: 6 }, () => query),
192 {
193 concurrency: "unbounded",
194 },
195 );
196 for (const result of results) {
197 expect(result.found).toBe(true);
198 }
199 }).pipe(logLevel),
200 {
201 tags: [
202 "provider:cloudflare",
203 "provider:cloudflare:hyperdrive",
204 "provider:cloudflare:worker",
205 "provider:neon",
206 "provider:neon:branch",
207 "provider:neon:project",
208 "provider:prisma",
209 "provider:prisma:orm",
210 "live",
211 ],
212 timeout: 120_000,
213 },
214);