1import * as Cloudflare from "@/Cloudflare";
2import * as Planetscale from "@/Planetscale";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import type { HttpClientResponse } from "effect/http/HttpClientResponse";
13import HyperdriveWorker from "./fixtures/hyperdrive-worker.ts";
14import type { Widget } from "./fixtures/schema.ts";
15import { Hyperdrive, PlanetscaleDb } from "./fixtures/Stack.ts";
16
17const providers = Layer.mergeAll(
18 Cloudflare.providers(),
19 Planetscale.providers(),
20);
21
22const { test } = Test.make({
23 providers,
24});
25
26const { test: devTest } = Test.make({
27 providers,
28 dev: true,
29});
30
31const logLevel = Effect.provideService(
32 MinimumLogLevel,
33 process.env.DEBUG ? "Debug" : "Info",
34);
35
36class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
37 status: number;
38 body: string;
39}> {}
40
41const fetchReady = (req: Effect.Effect<any, any, any>) =>
42 req.pipe(
43 Effect.flatMap((res: any) =>
44 res.status >= 200 && res.status < 300
45 ? res.text.pipe(Effect.as(res))
46 : res.text.pipe(
47 Effect.flatMap((body: string) =>
48 Effect.fail(new WorkerNotReady({ status: res.status, body })),
49 ),
50 ),
51 ),
52 Effect.retry({
53 while: (e: unknown): e is WorkerNotReady =>
54 e instanceof WorkerNotReady && e.status >= 400 && e.status < 600,
55 schedule: Schedule.max([
56 Schedule.exponential("500 millis"),
57 Schedule.recurs(20),
58 ]),
59 }),
60 ) as Effect.Effect<HttpClientResponse>;
61
62const expectWidgetRoundTrip = (baseUrl: string, widget: Widget) =>
63 Effect.gen(function* () {
64 const initial = yield* fetchReady(HttpClient.get(`${baseUrl}/widgets`));
65 expect(initial.status).toBe(200);
66 const initialBody = (yield* initial.json) as { widgets: Widget[] };
67 expect(Array.isArray(initialBody.widgets)).toBe(true);
68
69 const insertRes = yield* fetchReady(
70 HttpClient.execute(
71 HttpClientRequest.post(`${baseUrl}/widgets`).pipe(
72 HttpClientRequest.bodyJsonUnsafe(widget),
73 ),
74 ),
75 );
76 expect(insertRes.status).toBe(200);
77 const insertBody = (yield* insertRes.json) as { widget: Widget };
78 expect(insertBody.widget).toMatchObject(widget);
79
80 const after = yield* fetchReady(HttpClient.get(`${baseUrl}/widgets`));
81 expect(after.status).toBe(200);
82 const afterBody = (yield* after.json) as { widgets: Widget[] };
83 expect(afterBody.widgets.some((w) => w.id === widget.id)).toBe(true);
84
85 const deleteRes = yield* fetchReady(
86 HttpClient.execute(
87 HttpClientRequest.delete(`${baseUrl}/widgets/${widget.id}`),
88 ),
89 );
90 expect(deleteRes.status).toBe(200);
91
92 const final = yield* fetchReady(HttpClient.get(`${baseUrl}/widgets`));
93 const finalBody = (yield* final.json) as { widgets: Widget[] };
94 expect(finalBody.widgets.some((w) => w.id === widget.id)).toBe(false);
95 });
96
97describe.skipIf(!process.env.PLANETSCALE_TEST).sequential(
98 "Hyperdrive",
99 {
100 tags: [
101 "provider:cloudflare",
102 "provider:cloudflare:hyperdrive",
103 "provider:cloudflare:worker",
104 "provider:planetscale",
105 "provider:planetscale:postgres",
106 "live",
107 ],
108 },
109 () => {
110 /**
111 * End-to-end: deploy a {@link Planetscale.PostgresDatabase} + branch +
112 * role, point a {@link Cloudflare.Hyperdrive.Connection} at the role's origin, and
113 * exercise the Drizzle Effect client over real Postgres via a Worker.
114 *
115 * Validates that:
116 * - migrations applied from the fixtures dir produce the expected table
117 * - `Cloudflare.Hyperdrive.Connect(...) + Drizzle.Postgres(...)` produces
118 * a working Effect-native client at runtime
119 * - INSERT / SELECT / DELETE round-trip through Hyperdrive to Planetscale
120 */
121 test.provider(
122 "PostgresBranch + Hyperdrive + Drizzle round-trips through a Worker",
123 (stack) =>
124 Effect.gen(function* () {
125 yield* stack.destroy();
126
127 const { hyperdrive, worker } = yield* stack.deploy(
128 Effect.gen(function* () {
129 yield* PlanetscaleDb;
130 const hyperdrive = yield* Hyperdrive;
131 const worker = yield* HyperdriveWorker;
132 return { hyperdrive, worker };
133 }),
134 );
135
136 expect(worker.url).toBeTypeOf("string");
137 expect(hyperdrive.origin).toMatchObject({ port: 5432 });
138 expect(hyperdrive.dev).toMatchObject({ port: 6432 });
139 const baseUrl = (worker.url as string).replace(/\/+$/, "");
140 yield* expectWidgetRoundTrip(baseUrl, { id: 1, name: "alpha" });
141
142 yield* stack.destroy();
143 }).pipe(logLevel),
144 { timeout: 600_000 },
145 );
146
147 /**
148 * End-to-end: deploy the same fixture in local-dev mode. Hyperdrive is
149 * bypassed in local dev, so `role.pooledOrigin` must provide a working
150 * direct connection for the Worker runtime binding.
151 */
152 devTest.provider(
153 "PostgresRole pooledOrigin works as the Hyperdrive dev origin",
154 (stack) =>
155 Effect.gen(function* () {
156 yield* stack.destroy();
157
158 const { hyperdrive, role, worker } = yield* stack.deploy(
159 Effect.gen(function* () {
160 const { role } = yield* PlanetscaleDb;
161 const hyperdrive = yield* Hyperdrive;
162 const worker = yield* HyperdriveWorker;
163 return { hyperdrive, role, worker };
164 }),
165 );
166
167 expect(worker.url).toBeTypeOf("string");
168 expect(hyperdrive.origin).toMatchObject({ port: 5432 });
169 expect(hyperdrive.dev).toMatchObject({
170 host: role.pooledOrigin.host,
171 port: 6432,
172 database: role.pooledOrigin.database,
173 user: role.pooledOrigin.user,
174 });
175
176 const baseUrl = (worker.url as string).replace(/\/+$/, "");
177 const metadataRes = yield* fetchReady(
178 HttpClient.get(`${baseUrl}/hyperdrive`),
179 );
180 const metadata = (yield* metadataRes.json) as {
181 host: string;
182 port: number;
183 database: string;
184 user: string;
185 };
186 expect(metadata).toMatchObject({
187 host: role.pooledOrigin.host,
188 port: 6432,
189 database: role.pooledOrigin.database,
190 user: role.pooledOrigin.user,
191 });
192
193 yield* expectWidgetRoundTrip(baseUrl, { id: 2, name: "beta" });
194
195 yield* stack.destroy();
196 }).pipe(logLevel),
197 { timeout: 600_000 },
198 );
199 },
200);